Remove legacy tenant Filament panel
This commit is contained in:
@@ -1,187 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Pages;
|
|
||||||
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventJoinToken;
|
|
||||||
use App\Services\EventJoinTokenService;
|
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
|
|
||||||
class InviteStudio extends Page
|
|
||||||
{
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
|
|
||||||
|
|
||||||
protected string $view = 'filament.tenant.pages.invite-studio';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Einladungen & QR';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'invite-studio';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Einladungen & QR-Codes';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 50;
|
|
||||||
|
|
||||||
public ?int $selectedEventId = null;
|
|
||||||
|
|
||||||
public string $tokenLabel = '';
|
|
||||||
|
|
||||||
public array $tokens = [];
|
|
||||||
|
|
||||||
public array $layouts = [];
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = true;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
abort_if(! $tenant, 403);
|
|
||||||
|
|
||||||
if (! TenantOnboardingState::completed($tenant)) {
|
|
||||||
$this->redirect(TenantOnboarding::getUrl());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstEventId = $tenant->events()->orderBy('date')->value('id');
|
|
||||||
|
|
||||||
$this->selectedEventId = $firstEventId;
|
|
||||||
$this->layouts = $this->buildLayouts();
|
|
||||||
|
|
||||||
if ($this->selectedEventId) {
|
|
||||||
$this->loadEventContext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return TenantOnboardingState::completed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSelectedEventId(): void
|
|
||||||
{
|
|
||||||
$this->loadEventContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createInvite(EventJoinTokenService $service): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'selectedEventId' => ['required', 'exists:events,id'],
|
|
||||||
'tokenLabel' => ['nullable', 'string', 'max:120'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
abort_if(! $tenant, 403);
|
|
||||||
|
|
||||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
|
||||||
|
|
||||||
if (! $event) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Event konnte nicht gefunden werden')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.');
|
|
||||||
|
|
||||||
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
|
|
||||||
|
|
||||||
$service->createToken($event, [
|
|
||||||
'label' => $label,
|
|
||||||
'metadata' => [
|
|
||||||
'preferred_layout' => $layoutPreference,
|
|
||||||
],
|
|
||||||
'created_by' => auth()->id(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->tokenLabel = '';
|
|
||||||
|
|
||||||
$this->loadEventContext();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Neuer Einladungslink erstellt')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadEventContext(): void
|
|
||||||
{
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
if (! $tenant || ! $this->selectedEventId) {
|
|
||||||
$this->tokens = [];
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
|
|
||||||
|
|
||||||
if (! $event) {
|
|
||||||
$this->tokens = [];
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tokens = $event->joinTokens()
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
->map(fn (EventJoinToken $token) => $this->mapToken($event, $token))
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function mapToken(Event $event, EventJoinToken $token): array
|
|
||||||
{
|
|
||||||
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
|
||||||
'event' => $event->slug,
|
|
||||||
'joinToken' => $token->getKey(),
|
|
||||||
'layout' => $layoutId,
|
|
||||||
'format' => $format,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $token->getKey(),
|
|
||||||
'label' => $token->label ?? 'Einladungslink',
|
|
||||||
'url' => URL::to('/e/'.$token->token),
|
|
||||||
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
|
|
||||||
'usage_count' => $token->usage_count,
|
|
||||||
'usage_limit' => $token->usage_limit,
|
|
||||||
'active' => $token->isActive(),
|
|
||||||
'downloads' => $downloadUrls,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildLayouts(): array
|
|
||||||
{
|
|
||||||
return collect(JoinTokenLayoutRegistry::all())
|
|
||||||
->map(fn (array $layout) => [
|
|
||||||
'id' => $layout['id'],
|
|
||||||
'name' => $layout['name'],
|
|
||||||
'subtitle' => $layout['subtitle'] ?? '',
|
|
||||||
'description' => $layout['description'] ?? '',
|
|
||||||
])
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEventsProperty(): Collection
|
|
||||||
{
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant->events()->orderBy('date')->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Models\TaskCollection;
|
|
||||||
use App\Services\EventJoinTokenService;
|
|
||||||
use App\Services\Tenant\TaskCollectionImportService;
|
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Throwable;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class TenantOnboarding extends Page
|
|
||||||
{
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
|
||||||
|
|
||||||
protected string $view = 'filament.tenant.pages.onboarding';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Willkommen';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'willkommen';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Euer Start mit Fotospiel';
|
|
||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
|
||||||
|
|
||||||
public string $step = 'intro';
|
|
||||||
|
|
||||||
public array $status = [];
|
|
||||||
|
|
||||||
public array $inviteDownloads = [];
|
|
||||||
|
|
||||||
public array $selectedPackages = [];
|
|
||||||
|
|
||||||
public string $eventName = '';
|
|
||||||
|
|
||||||
public ?string $eventDate = null;
|
|
||||||
|
|
||||||
public ?int $eventTypeId = null;
|
|
||||||
|
|
||||||
public ?string $palette = null;
|
|
||||||
|
|
||||||
public ?string $inviteLayout = null;
|
|
||||||
|
|
||||||
public bool $isProcessing = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = true;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
abort_if(! $tenant, 403);
|
|
||||||
|
|
||||||
$this->status = TenantOnboardingState::status($tenant);
|
|
||||||
|
|
||||||
if (TenantOnboardingState::completed($tenant)) {
|
|
||||||
$this->redirect(EventResource::getUrl());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d');
|
|
||||||
$this->eventTypeId = $this->getDefaultEventTypeId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
return ! TenantOnboardingState::completed($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function start(): void
|
|
||||||
{
|
|
||||||
$this->step = 'packages';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function savePackages(): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'selectedPackages' => ['required', 'array', 'min:1'],
|
|
||||||
'selectedPackages.*' => ['integer', 'exists:task_collections,id'],
|
|
||||||
], [
|
|
||||||
'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->step = 'event';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveEvent(): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'eventName' => ['required', 'string', 'max:255'],
|
|
||||||
'eventDate' => ['required', 'date'],
|
|
||||||
'eventTypeId' => ['required', 'exists:event_types,id'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->step = 'palette';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function savePalette(): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'palette' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->step = 'invite';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function finish(
|
|
||||||
TaskCollectionImportService $importService,
|
|
||||||
EventJoinTokenService $joinTokenService
|
|
||||||
): void {
|
|
||||||
$this->validate([
|
|
||||||
'inviteLayout' => ['required', 'string'],
|
|
||||||
], [
|
|
||||||
'inviteLayout.required' => 'Bitte wählt ein Layout aus.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = TenantOnboardingState::tenant();
|
|
||||||
|
|
||||||
abort_if(! $tenant, 403);
|
|
||||||
|
|
||||||
$this->isProcessing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::transaction(function () use ($tenant, $importService, $joinTokenService) {
|
|
||||||
$event = $this->createEvent($tenant);
|
|
||||||
$this->importPackages($importService, $this->selectedPackages, $event);
|
|
||||||
|
|
||||||
$token = $joinTokenService->createToken($event, [
|
|
||||||
'label' => 'Fotospiel Einladung',
|
|
||||||
'metadata' => [
|
|
||||||
'preferred_layout' => $this->inviteLayout,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$settings = $tenant->settings ?? [];
|
|
||||||
Arr::set($settings, 'branding.palette', $this->palette);
|
|
||||||
Arr::set($settings, 'branding.primary_event_id', $event->id);
|
|
||||||
Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout);
|
|
||||||
$tenant->forceFill(['settings' => $settings])->save();
|
|
||||||
|
|
||||||
TenantOnboardingState::markCompleted($tenant, [
|
|
||||||
'primary_event_id' => $event->id,
|
|
||||||
'selected_packages' => $this->selectedPackages,
|
|
||||||
'qr_layout' => $this->inviteLayout,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->inviteDownloads = $this->buildInviteDownloads($event, $token);
|
|
||||||
$this->status = TenantOnboardingState::status($tenant);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Euer Setup ist bereit!')
|
|
||||||
->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->redirect(EventResource::getUrl('view', ['record' => $event]));
|
|
||||||
});
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
report($exception);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Setup konnte nicht abgeschlossen werden')
|
|
||||||
->body('Bitte prüft eure Eingaben oder versucht es später erneut.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
} finally {
|
|
||||||
$this->isProcessing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function createEvent($tenant): Event
|
|
||||||
{
|
|
||||||
$slugBase = Str::slug($this->eventName) ?: 'event';
|
|
||||||
|
|
||||||
do {
|
|
||||||
$slug = Str::of($slugBase)->append('-', Str::random(6))->lower();
|
|
||||||
} while (Event::where('slug', $slug)->exists());
|
|
||||||
|
|
||||||
return Event::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'name' => [
|
|
||||||
app()->getLocale() => $this->eventName,
|
|
||||||
'de' => $this->eventName,
|
|
||||||
],
|
|
||||||
'description' => null,
|
|
||||||
'date' => $this->eventDate,
|
|
||||||
'slug' => (string) $slug,
|
|
||||||
'event_type_id' => $this->eventTypeId,
|
|
||||||
'is_active' => true,
|
|
||||||
'default_locale' => app()->getLocale(),
|
|
||||||
'status' => 'draft',
|
|
||||||
'settings' => [
|
|
||||||
'appearance' => [
|
|
||||||
'palette' => $this->palette,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function importPackages(
|
|
||||||
TaskCollectionImportService $importService,
|
|
||||||
array $packageIds,
|
|
||||||
Event $event
|
|
||||||
): void {
|
|
||||||
if (empty($packageIds)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var EloquentCollection<TaskCollection> $collections */
|
|
||||||
$collections = TaskCollection::query()
|
|
||||||
->whereIn('id', $packageIds)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$collections->each(function (TaskCollection $collection) use ($importService, $event) {
|
|
||||||
$importService->import($collection, $event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function buildInviteDownloads(Event $event, $token): array
|
|
||||||
{
|
|
||||||
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
|
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
|
||||||
'event' => $event->slug,
|
|
||||||
'joinToken' => $token->getKey(),
|
|
||||||
'layout' => $layoutId,
|
|
||||||
'format' => $format,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPackageListProperty(): array
|
|
||||||
{
|
|
||||||
return TaskCollection::query()
|
|
||||||
->whereNull('tenant_id')
|
|
||||||
->orderBy('position')
|
|
||||||
->get()
|
|
||||||
->map(fn (TaskCollection $collection) => [
|
|
||||||
'id' => $collection->getKey(),
|
|
||||||
'name' => $collection->name,
|
|
||||||
'description' => $collection->description,
|
|
||||||
])
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEventTypeOptionsProperty(): array
|
|
||||||
{
|
|
||||||
return EventType::query()
|
|
||||||
->orderBy('name->'.app()->getLocale())
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(function (EventType $type) {
|
|
||||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
|
|
||||||
|
|
||||||
return [$type->getKey() => $name];
|
|
||||||
})
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPaletteOptionsProperty(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'romance' => [
|
|
||||||
'label' => 'Rosé & Gold',
|
|
||||||
'description' => 'Warme Rosé-Töne mit goldenen Akzenten – romantisch und elegant.',
|
|
||||||
],
|
|
||||||
'sunset' => [
|
|
||||||
'label' => 'Sonnenuntergang',
|
|
||||||
'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.',
|
|
||||||
],
|
|
||||||
'evergreen' => [
|
|
||||||
'label' => 'Evergreen',
|
|
||||||
'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.',
|
|
||||||
],
|
|
||||||
'midnight' => [
|
|
||||||
'label' => 'Midnight',
|
|
||||||
'description' => 'Tiefes Navy und Flieder – perfekt für elegante Abendveranstaltungen.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLayoutOptionsProperty(): array
|
|
||||||
{
|
|
||||||
return collect(JoinTokenLayoutRegistry::all())
|
|
||||||
->map(fn ($layout) => [
|
|
||||||
'id' => $layout['id'],
|
|
||||||
'name' => $layout['name'],
|
|
||||||
'subtitle' => $layout['subtitle'] ?? '',
|
|
||||||
'description' => $layout['description'] ?? '',
|
|
||||||
])
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getDefaultEventTypeId(): ?int
|
|
||||||
{
|
|
||||||
return EventType::query()->orderBy('name->'.app()->getLocale())->value('id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource\Pages;
|
|
||||||
use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventJoinTokenEvent;
|
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
use BackedEnum;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Forms\Components\Hidden;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EventResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Event::class;
|
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
|
|
||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.platform');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return TenantOnboardingState::completed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $form): Schema
|
|
||||||
{
|
|
||||||
$tenantId = Auth::user()?->tenant_id;
|
|
||||||
|
|
||||||
return $form->schema([
|
|
||||||
Hidden::make('tenant_id')
|
|
||||||
->default($tenantId)
|
|
||||||
->dehydrated(),
|
|
||||||
TextInput::make('name')
|
|
||||||
->label(__('admin.events.fields.name'))
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('slug')
|
|
||||||
->label(__('admin.events.fields.slug'))
|
|
||||||
->required()
|
|
||||||
->unique(ignoreRecord: true)
|
|
||||||
->maxLength(255),
|
|
||||||
DatePicker::make('date')
|
|
||||||
->label(__('admin.events.fields.date'))
|
|
||||||
->required(),
|
|
||||||
Select::make('event_type_id')
|
|
||||||
->label(__('admin.events.fields.type'))
|
|
||||||
->options(EventType::all()->pluck('name', 'id'))
|
|
||||||
->searchable(),
|
|
||||||
Select::make('package_id')
|
|
||||||
->label(__('admin.events.fields.package'))
|
|
||||||
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->required(),
|
|
||||||
TextInput::make('default_locale')
|
|
||||||
->label(__('admin.events.fields.default_locale'))
|
|
||||||
->default('de')
|
|
||||||
->maxLength(5),
|
|
||||||
Toggle::make('is_active')
|
|
||||||
->label(__('admin.events.fields.is_active'))
|
|
||||||
->default(true),
|
|
||||||
KeyValue::make('settings')
|
|
||||||
->label(__('admin.events.fields.settings'))
|
|
||||||
->keyLabel(__('admin.common.key'))
|
|
||||||
->valueLabel(__('admin.common.value')),
|
|
||||||
])->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
|
||||||
->label(__('admin.events.table.package'))
|
|
||||||
->badge()
|
|
||||||
->color('success'),
|
|
||||||
Tables\Columns\TextColumn::make('name')->limit(30),
|
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('date')->date(),
|
|
||||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('default_locale'),
|
|
||||||
Tables\Columns\TextColumn::make('eventPackage.used_photos')
|
|
||||||
->label(__('admin.events.table.used_photos'))
|
|
||||||
->badge(),
|
|
||||||
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
|
|
||||||
->label(__('admin.events.table.remaining_photos'))
|
|
||||||
->badge()
|
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
|
||||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
|
||||||
Tables\Columns\TextColumn::make('primary_join_token')
|
|
||||||
->label(__('admin.events.table.join'))
|
|
||||||
->getStateUsing(function ($record) {
|
|
||||||
$token = $record->joinTokens()->orderByDesc('created_at')->first();
|
|
||||||
|
|
||||||
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
|
||||||
})
|
|
||||||
->description(function ($record) {
|
|
||||||
$total = $record->joinTokens()->count();
|
|
||||||
|
|
||||||
return $total > 0
|
|
||||||
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
|
||||||
: __('admin.events.table.join_tokens_missing');
|
|
||||||
})
|
|
||||||
->copyable()
|
|
||||||
->copyMessage(__('admin.events.messages.join_link_copied')),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
|
||||||
])
|
|
||||||
->modifyQueryUsing(function (Builder $query) {
|
|
||||||
if ($tenantId = Auth::user()?->tenant_id) {
|
|
||||||
$query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->filters([])
|
|
||||||
->actions([
|
|
||||||
Actions\EditAction::make(),
|
|
||||||
Actions\Action::make('toggle')
|
|
||||||
->label(__('admin.events.actions.toggle_active'))
|
|
||||||
->icon('heroicon-o-power')
|
|
||||||
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
|
|
||||||
Actions\Action::make('join_tokens')
|
|
||||||
->label(__('admin.events.actions.join_link_qr'))
|
|
||||||
->icon('heroicon-o-qr-code')
|
|
||||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
|
||||||
->modalSubmitActionLabel(__('admin.common.close'))
|
|
||||||
->modalWidth('xl')
|
|
||||||
->modalContent(function ($record) {
|
|
||||||
$tokens = $record->joinTokens()
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($tokens->isEmpty()) {
|
|
||||||
return view('filament.events.join-link', [
|
|
||||||
'event' => $record,
|
|
||||||
'tokens' => collect(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenIds = $tokens->pluck('id');
|
|
||||||
$now = now();
|
|
||||||
|
|
||||||
$totals = EventJoinTokenEvent::query()
|
|
||||||
->selectRaw('event_join_token_id, event_type, COUNT(*) as total')
|
|
||||||
->whereIn('event_join_token_id', $tokenIds)
|
|
||||||
->groupBy('event_join_token_id', 'event_type')
|
|
||||||
->get()
|
|
||||||
->groupBy('event_join_token_id');
|
|
||||||
|
|
||||||
$recent24h = EventJoinTokenEvent::query()
|
|
||||||
->selectRaw('event_join_token_id, COUNT(*) as total')
|
|
||||||
->whereIn('event_join_token_id', $tokenIds)
|
|
||||||
->where('occurred_at', '>=', $now->copy()->subHours(24))
|
|
||||||
->groupBy('event_join_token_id')
|
|
||||||
->pluck('total', 'event_join_token_id');
|
|
||||||
|
|
||||||
$lastSeen = EventJoinTokenEvent::query()
|
|
||||||
->whereIn('event_join_token_id', $tokenIds)
|
|
||||||
->selectRaw('event_join_token_id, MAX(occurred_at) as last_at')
|
|
||||||
->groupBy('event_join_token_id')
|
|
||||||
->pluck('last_at', 'event_join_token_id');
|
|
||||||
|
|
||||||
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
|
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
|
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
|
||||||
'event' => $record->slug,
|
|
||||||
'joinToken' => $token->getKey(),
|
|
||||||
'layout' => $layoutId,
|
|
||||||
'format' => $format,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$analyticsGroup = $totals->get($token->id, collect());
|
|
||||||
$analytics = $analyticsGroup->mapWithKeys(function ($row) {
|
|
||||||
return [$row->event_type => (int) $row->total];
|
|
||||||
});
|
|
||||||
|
|
||||||
$successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0);
|
|
||||||
$failureCount = (int) ($analytics['invalid_token'] ?? 0)
|
|
||||||
+ (int) ($analytics['token_expired'] ?? 0)
|
|
||||||
+ (int) ($analytics['token_revoked'] ?? 0)
|
|
||||||
+ (int) ($analytics['token_rate_limited'] ?? 0)
|
|
||||||
+ (int) ($analytics['event_not_public'] ?? 0)
|
|
||||||
+ (int) ($analytics['gallery_expired'] ?? 0);
|
|
||||||
|
|
||||||
$lastSeenAt = $lastSeen->get($token->id);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $token->id,
|
|
||||||
'label' => $token->label,
|
|
||||||
'token' => $token->token,
|
|
||||||
'url' => url('/e/'.$token->token),
|
|
||||||
'usage_limit' => $token->usage_limit,
|
|
||||||
'usage_count' => $token->usage_count,
|
|
||||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
|
||||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
|
||||||
'is_active' => $token->isActive(),
|
|
||||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
|
||||||
'layouts' => $layouts,
|
|
||||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
|
||||||
'event' => $record->slug,
|
|
||||||
'joinToken' => $token->getKey(),
|
|
||||||
]),
|
|
||||||
'analytics' => [
|
|
||||||
'success_total' => $successCount,
|
|
||||||
'failure_total' => $failureCount,
|
|
||||||
'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0),
|
|
||||||
'recent_24h' => (int) $recent24h->get($token->id, 0),
|
|
||||||
'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return view('filament.events.join-link', [
|
|
||||||
'event' => $record,
|
|
||||||
'tokens' => $tokens,
|
|
||||||
]);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\DeleteBulkAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListEvents::route('/'),
|
|
||||||
'create' => Pages\CreateEvent::route('/create'),
|
|
||||||
'view' => Pages\ViewEvent::route('/{record}'),
|
|
||||||
'edit' => Pages\EditEvent::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getRelations(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
EventPackagesRelationManager::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateEvent extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EventResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditEvent extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EventResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListEvents extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = EventResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\EventResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewEvent extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EventResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\EventResource\RelationManagers;
|
|
||||||
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Actions\CreateAction;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\DeleteBulkAction;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
||||||
use App\Models\EventPackage;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class EventPackagesRelationManager extends RelationManager
|
|
||||||
{
|
|
||||||
protected static string $relationship = 'eventPackages';
|
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Select::make('package_id')
|
|
||||||
->label('Package')
|
|
||||||
->relationship('package', 'name')
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->required(),
|
|
||||||
TextInput::make('purchased_price')
|
|
||||||
->label('Kaufpreis')
|
|
||||||
->prefix('€')
|
|
||||||
->numeric()
|
|
||||||
->step(0.01)
|
|
||||||
->required(),
|
|
||||||
TextInput::make('used_photos')
|
|
||||||
->label('Verwendete Fotos')
|
|
||||||
->numeric()
|
|
||||||
->default(0)
|
|
||||||
->readOnly(),
|
|
||||||
TextInput::make('used_guests')
|
|
||||||
->label('Verwendete Gäste')
|
|
||||||
->numeric()
|
|
||||||
->default(0)
|
|
||||||
->readOnly(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->recordTitleAttribute('package.name')
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('package.name')
|
|
||||||
->label('Package')
|
|
||||||
->badge()
|
|
||||||
->color('success'),
|
|
||||||
TextColumn::make('used_photos')
|
|
||||||
->label('Verwendete Fotos')
|
|
||||||
->badge(),
|
|
||||||
TextColumn::make('remaining_photos')
|
|
||||||
->label('Verbleibende Fotos')
|
|
||||||
->badge()
|
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
|
||||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
|
|
||||||
TextColumn::make('used_guests')
|
|
||||||
->label('Verwendete Gäste')
|
|
||||||
->badge(),
|
|
||||||
TextColumn::make('remaining_guests')
|
|
||||||
->label('Verbleibende Gäste')
|
|
||||||
->badge()
|
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
|
||||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
|
|
||||||
TextColumn::make('expires_at')
|
|
||||||
->label('Ablauf')
|
|
||||||
->dateTime()
|
|
||||||
->badge()
|
|
||||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
|
|
||||||
TextColumn::make('price')
|
|
||||||
->label('Preis')
|
|
||||||
->money('EUR')
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
//
|
|
||||||
])
|
|
||||||
->headerActions([
|
|
||||||
CreateAction::make(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
EditAction::make(),
|
|
||||||
DeleteAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([
|
|
||||||
DeleteBulkAction::make(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRelationExistenceQuery(
|
|
||||||
Builder $query,
|
|
||||||
string $relationshipName,
|
|
||||||
?string $ownerKeyName,
|
|
||||||
mixed $ownerKeyValue,
|
|
||||||
): Builder {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getTitle(Model $ownerRecord, string $pageClass): string
|
|
||||||
{
|
|
||||||
return __('admin.events.relation_managers.event_packages.title');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTableQuery(): Builder | Relation
|
|
||||||
{
|
|
||||||
return parent::getTableQuery()
|
|
||||||
->with('package');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PhotoResource\Pages;
|
|
||||||
use App\Models\Photo;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Components\FileUpload;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use UnitEnum;
|
|
||||||
use BackedEnum;
|
|
||||||
|
|
||||||
class PhotoResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Photo::class;
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.content');
|
|
||||||
}
|
|
||||||
protected static ?int $navigationSort = 30;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return TenantOnboardingState::completed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $form): Schema
|
|
||||||
{
|
|
||||||
$tenantId = Auth::user()?->tenant_id;
|
|
||||||
|
|
||||||
return $form->schema([
|
|
||||||
Select::make('event_id')
|
|
||||||
->label(__('admin.photos.fields.event'))
|
|
||||||
->options(
|
|
||||||
Event::query()
|
|
||||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->pluck('name', 'id')
|
|
||||||
)
|
|
||||||
->searchable()
|
|
||||||
->required(),
|
|
||||||
FileUpload::make('file_path')
|
|
||||||
->label(__('admin.photos.fields.photo'))
|
|
||||||
->image() // enable FilePond image preview
|
|
||||||
->disk('public')
|
|
||||||
->directory('photos')
|
|
||||||
->visibility('public')
|
|
||||||
->required(),
|
|
||||||
Toggle::make('is_featured')
|
|
||||||
->label(__('admin.photos.fields.is_featured'))
|
|
||||||
->default(false),
|
|
||||||
KeyValue::make('metadata')
|
|
||||||
->label(__('admin.photos.fields.metadata'))
|
|
||||||
->keyLabel(__('admin.common.key'))
|
|
||||||
->valueLabel(__('admin.common.value')),
|
|
||||||
])->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'),
|
|
||||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('event.name')->label(__('admin.photos.table.event'))->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')),
|
|
||||||
Tables\Columns\IconColumn::make('is_featured')->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
|
||||||
])
|
|
||||||
->modifyQueryUsing(function (Builder $query) {
|
|
||||||
if ($tenantId = Auth::user()?->tenant_id) {
|
|
||||||
$query->whereHas('event', fn (Builder $eventQuery) => $eventQuery->where('tenant_id', $tenantId));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->filters([])
|
|
||||||
->actions([
|
|
||||||
Actions\EditAction::make(),
|
|
||||||
Actions\Action::make('feature')
|
|
||||||
->label(__('admin.photos.actions.feature'))
|
|
||||||
->visible(fn($record) => !$record->is_featured)
|
|
||||||
->action(fn($record) => $record->update(['is_featured' => true]))
|
|
||||||
->icon('heroicon-o-star'),
|
|
||||||
Actions\Action::make('unfeature')
|
|
||||||
->label(__('admin.photos.actions.unfeature'))
|
|
||||||
->visible(fn($record) => $record->is_featured)
|
|
||||||
->action(fn($record) => $record->update(['is_featured' => false]))
|
|
||||||
->icon('heroicon-o-star'),
|
|
||||||
Actions\DeleteAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\BulkAction::make('feature')
|
|
||||||
->label(__('admin.photos.actions.feature_selected'))
|
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->action(fn($records) => $records->each->update(['is_featured' => true])),
|
|
||||||
Actions\BulkAction::make('unfeature')
|
|
||||||
->label(__('admin.photos.actions.unfeature_selected'))
|
|
||||||
->icon('heroicon-o-star')
|
|
||||||
->action(fn($records) => $records->each->update(['is_featured' => false])),
|
|
||||||
Actions\DeleteBulkAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListPhotos::route('/'),
|
|
||||||
'view' => Pages\ViewPhoto::route('/{record}'),
|
|
||||||
'edit' => Pages\EditPhoto::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PhotoResource;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditPhoto extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = PhotoResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PhotoResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListPhotos extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = PhotoResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\PhotoResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewPhoto extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = PhotoResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Models\TaskCollection;
|
|
||||||
use App\Services\Tenant\TaskCollectionImportService;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
|
||||||
use Filament\Tables\Columns\IconColumn;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use BackedEnum;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
|
|
||||||
class TaskCollectionResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = TaskCollection::class;
|
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-folder';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 50;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return TenantOnboardingState::completed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): string
|
|
||||||
{
|
|
||||||
return __('admin.nav.library');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
$tenantId = auth()->user()?->tenant_id;
|
|
||||||
|
|
||||||
return $schema->components([
|
|
||||||
Section::make(__('Task Collection Details'))
|
|
||||||
->schema([
|
|
||||||
TextInput::make('name_translations.de')
|
|
||||||
->label(__('Name (DE)'))
|
|
||||||
->required()
|
|
||||||
->maxLength(255)
|
|
||||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
|
||||||
TextInput::make('name_translations.en')
|
|
||||||
->label(__('Name (EN)'))
|
|
||||||
->maxLength(255)
|
|
||||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
|
||||||
Select::make('event_type_id')
|
|
||||||
->label(__('Event Type'))
|
|
||||||
->options(fn () => EventType::orderBy('name->' . app()->getLocale())
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(function (EventType $type) {
|
|
||||||
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? reset($type->name);
|
|
||||||
|
|
||||||
return [$type->id => $name];
|
|
||||||
})->toArray())
|
|
||||||
->searchable()
|
|
||||||
->required()
|
|
||||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
|
||||||
Textarea::make('description_translations.de')
|
|
||||||
->label(__('Description (DE)'))
|
|
||||||
->rows(3)
|
|
||||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
|
||||||
Textarea::make('description_translations.en')
|
|
||||||
->label(__('Description (EN)'))
|
|
||||||
->rows(3)
|
|
||||||
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
|
|
||||||
])->columns(2),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('name')
|
|
||||||
->label(__('Name'))
|
|
||||||
->searchable(['name_translations->de', 'name_translations->en'])
|
|
||||||
->sortable(),
|
|
||||||
BadgeColumn::make('eventType.name')
|
|
||||||
->label(__('Event Type'))
|
|
||||||
->color('info'),
|
|
||||||
IconColumn::make('tenant_id')
|
|
||||||
->label(__('Scope'))
|
|
||||||
->boolean()
|
|
||||||
->trueIcon('heroicon-o-user-group')
|
|
||||||
->falseIcon('heroicon-o-globe-alt')
|
|
||||||
->state(fn (TaskCollection $record) => $record->tenant_id !== null)
|
|
||||||
->tooltip(fn (TaskCollection $record) => $record->tenant_id ? __('Tenant-only') : __('Global template')),
|
|
||||||
TextColumn::make('tasks_count')
|
|
||||||
->label(__('Tasks'))
|
|
||||||
->counts('tasks')
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('event_type_id')
|
|
||||||
->label(__('Event Type'))
|
|
||||||
->relationship('eventType', 'name->' . app()->getLocale()),
|
|
||||||
SelectFilter::make('scope')
|
|
||||||
->options([
|
|
||||||
'global' => __('Global template'),
|
|
||||||
'tenant' => __('Tenant-owned'),
|
|
||||||
])
|
|
||||||
->query(function ($query, $value) {
|
|
||||||
$tenantId = auth()->user()?->tenant_id;
|
|
||||||
|
|
||||||
if ($value === 'global') {
|
|
||||||
$query->whereNull('tenant_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value === 'tenant') {
|
|
||||||
$query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
\Filament\Actions\Action::make('import')
|
|
||||||
->label(__('Import to Event'))
|
|
||||||
->icon('heroicon-o-cloud-arrow-down')
|
|
||||||
->form([
|
|
||||||
Select::make('event_slug')
|
|
||||||
->label(__('Select Event'))
|
|
||||||
->options(function () {
|
|
||||||
$tenantId = auth()->user()?->tenant_id;
|
|
||||||
|
|
||||||
return Event::where('tenant_id', $tenantId)
|
|
||||||
->orderBy('date', 'desc')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(function (Event $event) {
|
|
||||||
$name = $event->name[app()->getLocale()] ?? $event->name['de'] ?? reset($event->name);
|
|
||||||
|
|
||||||
return [
|
|
||||||
$event->slug => sprintf('%s (%s)', $name, $event->date?->format('d.m.Y')),
|
|
||||||
];
|
|
||||||
})->toArray();
|
|
||||||
})
|
|
||||||
->required()
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->action(function (TaskCollection $record, array $data) {
|
|
||||||
$event = Event::where('slug', $data['event_slug'])
|
|
||||||
->where('tenant_id', auth()->user()?->tenant_id)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
/** @var TaskCollectionImportService $service */
|
|
||||||
$service = app(TaskCollectionImportService::class);
|
|
||||||
$service->import($record, $event);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Task collection imported'))
|
|
||||||
->body(__('The collection :name has been imported.', ['name' => $record->name]))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Actions\EditAction::make()
|
|
||||||
->label(__('Edit'))
|
|
||||||
->visible(fn (TaskCollection $record) => $record->tenant_id === auth()->user()?->tenant_id),
|
|
||||||
])
|
|
||||||
->headerActions([
|
|
||||||
Actions\CreateAction::make()
|
|
||||||
->label(__('Create Task Collection'))
|
|
||||||
->mutateFormDataUsing(function (array $data) {
|
|
||||||
$tenantId = auth()->user()?->tenant_id;
|
|
||||||
|
|
||||||
$data['tenant_id'] = $tenantId;
|
|
||||||
$data['slug'] = static::generateSlug($data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', $tenantId);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\DeleteBulkAction::make()
|
|
||||||
->visible(fn () => false),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListTaskCollections::route('/'),
|
|
||||||
'create' => Pages\CreateTaskCollection::route('/create'),
|
|
||||||
'edit' => Pages\EditTaskCollection::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
$tenantId = auth()->user()?->tenant_id;
|
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->forTenant($tenantId)
|
|
||||||
->with('eventType')
|
|
||||||
->withCount('tasks');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getGloballySearchableAttributes(): array
|
|
||||||
{
|
|
||||||
return ['name_translations->de', 'name_translations->en'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function generateSlug(string $base, int $tenantId): string
|
|
||||||
{
|
|
||||||
$slugBase = Str::slug($base) ?: 'collection';
|
|
||||||
|
|
||||||
do {
|
|
||||||
$candidate = $slugBase . '-' . $tenantId . '-' . Str::random(4);
|
|
||||||
} while (TaskCollection::where('slug', $candidate)->exists());
|
|
||||||
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
|
|
||||||
{
|
|
||||||
$tenant ??= Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where(function (Builder $innerQuery) use ($tenant) {
|
|
||||||
$innerQuery->whereNull('tenant_id')
|
|
||||||
->orWhere('tenant_id', $tenant->getKey());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class CreateTaskCollection extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
$tenantId = Auth::user()?->tenant_id;
|
|
||||||
|
|
||||||
$data['tenant_id'] = $tenantId;
|
|
||||||
$data['slug'] = TaskCollectionResource::generateSlug(
|
|
||||||
$data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection',
|
|
||||||
$tenantId
|
|
||||||
);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
class EditTaskCollection extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
|
||||||
{
|
|
||||||
parent::authorizeAccess();
|
|
||||||
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if ($record->tenant_id !== Auth::user()?->tenant_id) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTaskCollections extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskCollectionResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskResource\Pages;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Support\TenantOnboardingState;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\MarkdownEditor;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Form;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class TaskResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = Task::class;
|
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 40;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return TenantOnboardingState::completed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.library');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $form): Schema
|
|
||||||
{
|
|
||||||
$tenantId = Auth::user()?->tenant_id;
|
|
||||||
|
|
||||||
return $form->schema([
|
|
||||||
Select::make('emotion_id')
|
|
||||||
->relationship('emotion', 'name')
|
|
||||||
->required()
|
|
||||||
->searchable()
|
|
||||||
->preload(),
|
|
||||||
Select::make('event_type_id')
|
|
||||||
->relationship('eventType', 'name')
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->label(__('admin.tasks.fields.event_type_optional')),
|
|
||||||
SchemaTabs::make('content_tabs')
|
|
||||||
->label(__('admin.tasks.fields.content_localization'))
|
|
||||||
->tabs([
|
|
||||||
SchemaTab::make(__('admin.common.german'))
|
|
||||||
->icon('heroicon-o-language')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('title.de')
|
|
||||||
->label(__('admin.tasks.fields.title_de'))
|
|
||||||
->required(),
|
|
||||||
MarkdownEditor::make('description.de')
|
|
||||||
->label(__('admin.tasks.fields.description_de'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
MarkdownEditor::make('example_text.de')
|
|
||||||
->label(__('admin.tasks.fields.example_de'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
]),
|
|
||||||
SchemaTab::make(__('admin.common.english'))
|
|
||||||
->icon('heroicon-o-language')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('title.en')
|
|
||||||
->label(__('admin.tasks.fields.title_en'))
|
|
||||||
->required(),
|
|
||||||
MarkdownEditor::make('description.en')
|
|
||||||
->label(__('admin.tasks.fields.description_en'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
MarkdownEditor::make('example_text.en')
|
|
||||||
->label(__('admin.tasks.fields.example_en'))
|
|
||||||
->columnSpanFull(),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Select::make('difficulty')
|
|
||||||
->label(__('admin.tasks.fields.difficulty.label'))
|
|
||||||
->options([
|
|
||||||
'easy' => __('admin.tasks.fields.difficulty.easy'),
|
|
||||||
'medium' => __('admin.tasks.fields.difficulty.medium'),
|
|
||||||
'hard' => __('admin.tasks.fields.difficulty.hard'),
|
|
||||||
])
|
|
||||||
->default('easy'),
|
|
||||||
TextInput::make('sort_order')
|
|
||||||
->numeric()
|
|
||||||
->default(0),
|
|
||||||
Toggle::make('is_active')
|
|
||||||
->default(true),
|
|
||||||
Select::make('assigned_events')
|
|
||||||
->label(__('admin.tasks.fields.events'))
|
|
||||||
->multiple()
|
|
||||||
->relationship(
|
|
||||||
'assignedEvents',
|
|
||||||
'name',
|
|
||||||
fn (Builder $query) => $tenantId
|
|
||||||
? $query->where('tenant_id', $tenantId)
|
|
||||||
: $query
|
|
||||||
)
|
|
||||||
->searchable()
|
|
||||||
->preload()
|
|
||||||
->getOptionLabelFromRecordUsing(fn (Event $record) => $record->name)
|
|
||||||
->helperText(__('admin.tasks.fields.events_helper')),
|
|
||||||
])->columns(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
$tenantId = Auth::user()?->tenant_id;
|
|
||||||
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('id')
|
|
||||||
->label('#')
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('title')
|
|
||||||
->label(__('admin.tasks.table.title'))
|
|
||||||
->getStateUsing(function ($record) {
|
|
||||||
$value = $record->title;
|
|
||||||
if (is_array($value)) {
|
|
||||||
$loc = app()->getLocale();
|
|
||||||
return $value[$loc] ?? ($value['de'] ?? ($value['en'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $value;
|
|
||||||
})
|
|
||||||
->limit(60)
|
|
||||||
->searchable(['title->de', 'title->en']),
|
|
||||||
Tables\Columns\TextColumn::make('emotion.name')
|
|
||||||
->label(__('admin.tasks.fields.emotion'))
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('eventType.name')
|
|
||||||
->label(__('admin.tasks.fields.event_type'))
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('assignedEvents.name')
|
|
||||||
->label(__('admin.tasks.table.events'))
|
|
||||||
->badge()
|
|
||||||
->separator(', ')
|
|
||||||
->limitList(2),
|
|
||||||
Tables\Columns\TextColumn::make('difficulty')
|
|
||||||
->label(__('admin.tasks.fields.difficulty.label'))
|
|
||||||
->badge(),
|
|
||||||
Tables\Columns\IconColumn::make('is_active')
|
|
||||||
->label(__('admin.tasks.table.is_active'))
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('sort_order')
|
|
||||||
->label(__('admin.tasks.table.sort_order'))
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([])
|
|
||||||
->actions([
|
|
||||||
Actions\EditAction::make(),
|
|
||||||
Actions\DeleteAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\DeleteBulkAction::make(),
|
|
||||||
])
|
|
||||||
->modifyQueryUsing(function (Builder $query) use ($tenantId) {
|
|
||||||
if (! $tenantId) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->forTenant($tenantId);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListTasks::route('/'),
|
|
||||||
'create' => Pages\CreateTask::route('/create'),
|
|
||||||
'edit' => Pages\EditTask::route('/{record}/edit'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
|
|
||||||
{
|
|
||||||
$tenant ??= Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where(function (Builder $innerQuery) use ($tenant) {
|
|
||||||
$innerQuery->whereNull('tenant_id')
|
|
||||||
->orWhere('tenant_id', $tenant->getKey());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskResource;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
|
||||||
|
|
||||||
class CreateTask extends CreateRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskResource::class;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\EditRecord;
|
|
||||||
|
|
||||||
class EditTask extends EditRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\DeleteAction::make(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Tenant\Resources\TaskResource;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTasks extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TaskResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\CreateAction::make(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Widgets;
|
|
||||||
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Widgets\TableWidget as BaseWidget;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use App\Models\Event;
|
|
||||||
|
|
||||||
class EventsActiveToday extends BaseWidget
|
|
||||||
{
|
|
||||||
protected static ?string $heading = null;
|
|
||||||
|
|
||||||
public function getHeading()
|
|
||||||
{
|
|
||||||
return __('admin.widgets.events_active_today.heading');
|
|
||||||
}
|
|
||||||
protected ?string $pollingInterval = '60s';
|
|
||||||
|
|
||||||
public function table(Tables\Table $table): Tables\Table
|
|
||||||
{
|
|
||||||
$today = Carbon::today()->toDateString();
|
|
||||||
return $table
|
|
||||||
->query(
|
|
||||||
Event::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->whereDate('date', '<=', $today)
|
|
||||||
->withCount([
|
|
||||||
'photos as uploads_today' => function ($q) use ($today) {
|
|
||||||
$q->whereDate('created_at', $today);
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->orderByDesc('date')
|
|
||||||
->limit(10)
|
|
||||||
)
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash'))->width('60px'),
|
|
||||||
Tables\Columns\TextColumn::make('slug')->label(__('admin.common.slug'))->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('date')->date(),
|
|
||||||
Tables\Columns\TextColumn::make('uploads_today')->label(__('admin.common.uploads_today'))->numeric(),
|
|
||||||
])
|
|
||||||
->paginated(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Widgets;
|
|
||||||
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Widgets\TableWidget as BaseWidget;
|
|
||||||
use App\Models\Photo;
|
|
||||||
use Filament\Actions;
|
|
||||||
|
|
||||||
class RecentPhotosTable extends BaseWidget
|
|
||||||
{
|
|
||||||
protected static ?string $heading = null;
|
|
||||||
|
|
||||||
public function getHeading()
|
|
||||||
{
|
|
||||||
return __('admin.widgets.recent_uploads.heading');
|
|
||||||
}
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Tables\Table $table): Tables\Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(
|
|
||||||
Photo::query()
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->limit(10)
|
|
||||||
)
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\ImageColumn::make('thumbnail_path')->label(__('admin.common.thumb'))->circular(),
|
|
||||||
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash')),
|
|
||||||
Tables\Columns\TextColumn::make('event_id')->label(__('admin.common.event')),
|
|
||||||
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.common.likes')),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('feature')
|
|
||||||
->label(__('admin.photos.actions.feature'))
|
|
||||||
->visible(fn(Photo $record) => ! (bool)($record->is_featured ?? 0))
|
|
||||||
->action(fn(Photo $record) => $record->update(['is_featured' => 1])),
|
|
||||||
Actions\Action::make('unfeature')
|
|
||||||
->label(__('admin.photos.actions.unfeature'))
|
|
||||||
->visible(fn(Photo $record) => (bool)($record->is_featured ?? 0))
|
|
||||||
->action(fn(Photo $record) => $record->update(['is_featured' => 0])),
|
|
||||||
])
|
|
||||||
->paginated(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Tenant\Widgets;
|
|
||||||
use Filament\Widgets\ChartWidget;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
class UploadsPerDayChart extends ChartWidget
|
|
||||||
{
|
|
||||||
protected ?string $heading = null;
|
|
||||||
protected ?string $maxHeight = '220px';
|
|
||||||
protected ?string $pollingInterval = '60s';
|
|
||||||
|
|
||||||
protected function getData(): array
|
|
||||||
{
|
|
||||||
// Build last 14 days labels
|
|
||||||
$labels = [];
|
|
||||||
$start = Carbon::now()->startOfDay()->subDays(13);
|
|
||||||
for ($i = 0; $i < 14; $i++) {
|
|
||||||
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite-friendly group by date
|
|
||||||
$rows = DB::table('photos')
|
|
||||||
->selectRaw("strftime('%Y-%m-%d', created_at) as d, count(*) as c")
|
|
||||||
->where('created_at', '>=', $start)
|
|
||||||
->groupBy('d')
|
|
||||||
->orderBy('d')
|
|
||||||
->get();
|
|
||||||
$map = collect($rows)->keyBy('d');
|
|
||||||
$data = array_map(fn ($d) => (int) ($map[$d]->c ?? 0), $labels);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'labels' => $labels,
|
|
||||||
'datasets' => [
|
|
||||||
[
|
|
||||||
'label' => __('admin.common.uploads'),
|
|
||||||
'data' => $data,
|
|
||||||
'borderColor' => '#f59e0b',
|
|
||||||
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
|
|
||||||
'tension' => 0.3,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getType(): string
|
|
||||||
{
|
|
||||||
return 'line';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getHeading(): string|\Illuminate\Contracts\Support\Htmlable|null
|
|
||||||
{
|
|
||||||
return __('admin.widgets.uploads_per_day.heading');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -156,7 +156,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
// Super admins go to Filament superadmin panel
|
// Super admins go to Filament superadmin panel
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->role === 'super_admin') {
|
||||||
return '/admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant admins go to their PWA dashboard
|
// Tenant admins go to their PWA dashboard
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->role === 'super_admin') {
|
||||||
return '/admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'user') {
|
if ($user && $user->role === 'user') {
|
||||||
|
|||||||
@@ -301,8 +301,5 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
|
||||||
$this->app->register(\App\Providers\Filament\AdminPanelProvider::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers\Filament;
|
|
||||||
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|
||||||
use Filament\Pages;
|
|
||||||
use Filament\Panel;
|
|
||||||
use Filament\PanelProvider;
|
|
||||||
use Filament\Support\Colors\Color;
|
|
||||||
use Filament\Widgets;
|
|
||||||
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
|
|
||||||
->id('admin')
|
|
||||||
->path('admin')
|
|
||||||
->brandName('Fotospiel Studio')
|
|
||||||
->login(\App\Filament\Pages\Auth\Login::class)
|
|
||||||
->colors([
|
|
||||||
'primary' => Color::Pink,
|
|
||||||
])
|
|
||||||
->homeUrl(fn () => \App\Filament\Tenant\Pages\TenantOnboarding::getUrl())
|
|
||||||
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
|
|
||||||
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
|
|
||||||
->pages([])
|
|
||||||
->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets')
|
|
||||||
->widgets([
|
|
||||||
Widgets\AccountWidget::class,
|
|
||||||
])
|
|
||||||
->middleware([
|
|
||||||
EncryptCookies::class,
|
|
||||||
AddQueuedCookiesToResponse::class,
|
|
||||||
StartSession::class,
|
|
||||||
AuthenticateSession::class,
|
|
||||||
ShareErrorsFromSession::class,
|
|
||||||
VerifyCsrfToken::class,
|
|
||||||
SubstituteBindings::class,
|
|
||||||
DisableBladeIconComponents::class,
|
|
||||||
DispatchServingFilamentEvent::class,
|
|
||||||
])
|
|
||||||
->authMiddleware([
|
|
||||||
Authenticate::class,
|
|
||||||
])
|
|
||||||
->tenant(\App\Models\Tenant::class)
|
|
||||||
// Remove blog models as they are global and handled in SuperAdmin
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<x-filament-panels::page>
|
|
||||||
<div class="mx-auto w-full max-w-4xl space-y-10">
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-sky-100/50">
|
|
||||||
<header class="space-y-3">
|
|
||||||
<h1 class="text-3xl font-semibold text-slate-900">Einladungen & QR-Codes</h1>
|
|
||||||
<p class="text-sm text-slate-600">
|
|
||||||
Erstellt und verwaltet eure QR-Einladungen. Jeder Link enthält druckfertige Layouts als PDF & SVG.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-6 md:grid-cols-[2fr,1fr]">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-slate-700">
|
|
||||||
Event auswählen
|
|
||||||
<select
|
|
||||||
wire:model="selectedEventId"
|
|
||||||
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
|
|
||||||
<option value="">Bitte wählt ein Event</option>
|
|
||||||
@foreach ($this->events as $event)
|
|
||||||
<option value="{{ $event->id }}">{{ data_get($event->name, app()->getLocale()) ?? data_get($event->name, 'de') ?? 'Event #' . $event->id }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-700 shadow-inner">
|
|
||||||
<p class="font-semibold">Tipp</p>
|
|
||||||
<p class="mt-2">Druckt mehrere Layouts aus und verteilt sie am Eingang, am Gästebuch und beim DJ-Pult.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-rose-100/50">
|
|
||||||
<form wire:submit.prevent="createInvite" class="grid gap-4 md:grid-cols-[2fr,1fr] md:items-end">
|
|
||||||
<label class="block text-sm font-semibold text-slate-700">
|
|
||||||
Bezeichnung des Links (optional)
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
wire:model.defer="tokenLabel"
|
|
||||||
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
|
|
||||||
placeholder="z. B. Empfang, Fotobox, Tanzfläche" />
|
|
||||||
</label>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:opacity-70"
|
|
||||||
@disabled($this->events->isEmpty())
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
wire:target="createInvite">
|
|
||||||
<span wire:loading.remove wire:target="createInvite">Neuen Einladungslink erzeugen</span>
|
|
||||||
<span wire:loading wire:target="createInvite" class="flex items-center gap-2">
|
|
||||||
<x-filament::loading-indicator class="h-4 w-4" />
|
|
||||||
Wird erstellt …
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@if ($this->events->isEmpty())
|
|
||||||
<p class="mt-4 text-sm text-slate-600">Legt zunächst ein Event an, um Einladungslinks zu erstellen.</p>
|
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-8 shadow-xl shadow-slate-100/60">
|
|
||||||
<h2 class="text-2xl font-semibold text-slate-900">Aktive Einladungslinks</h2>
|
|
||||||
|
|
||||||
@if (empty($tokens))
|
|
||||||
<p class="mt-4 text-sm text-slate-600">
|
|
||||||
Noch keine Einladungen erstellt. Generiert euren ersten Link, um die QR-Codes als PDF oder SVG herunterzuladen.
|
|
||||||
</p>
|
|
||||||
@else
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
@foreach ($tokens as $token)
|
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm">
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-slate-900">{{ $token['label'] }}</h3>
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
{{ $token['url'] }} · erstellt am {{ $token['created_at'] }} · Aufrufe: {{ $token['usage_count'] }}{{ $token['usage_limit'] ? ' / ' . $token['usage_limit'] : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<a
|
|
||||||
href="{{ $token['url'] }}"
|
|
||||||
target="_blank"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
|
|
||||||
Link öffnen
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
x-data="{ copied: false }"
|
|
||||||
x-on:click="navigator.clipboard.writeText('{{ $token['url'] }}').then(() => { copied = true; setTimeout(() => copied = false, 1500); })"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
|
|
||||||
<span x-show="!copied">Link kopieren</span>
|
|
||||||
<span x-cloak x-show="copied" class="text-rose-500">Kopiert!</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
@foreach ($token['downloads'] as $layout)
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-600">
|
|
||||||
<p class="font-semibold text-slate-900">{{ $layout['name'] }}</p>
|
|
||||||
<p class="text-xs text-slate-500">{{ $layout['subtitle'] }}</p>
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
@foreach ($layout['download_urls'] as $format => $url)
|
|
||||||
<a
|
|
||||||
href="{{ $url }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600"
|
|
||||||
target="_blank">
|
|
||||||
{{ strtoupper($format) }} herunterladen
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
@php
|
|
||||||
$steps = [
|
|
||||||
'intro' => 'Willkommen',
|
|
||||||
'packages' => 'Aufgaben wählen',
|
|
||||||
'event' => 'Event benennen',
|
|
||||||
'palette' => 'Farbwelt',
|
|
||||||
'invite' => 'Einladungen',
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament-panels::page>
|
|
||||||
<div class="mx-auto w-full max-w-4xl space-y-10">
|
|
||||||
<section class="rounded-3xl border border-white/60 bg-white/80 p-8 shadow-xl shadow-rose-100/40 backdrop-blur-md">
|
|
||||||
<header class="space-y-4 text-center">
|
|
||||||
<p class="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
|
|
||||||
Fotospiel Studio · Geführter Start
|
|
||||||
</p>
|
|
||||||
<h1 class="font-display text-4xl font-semibold tracking-tight text-slate-900">
|
|
||||||
Eure Gäste werden zu Geschichtenerzähler:innen
|
|
||||||
</h1>
|
|
||||||
<p class="mx-auto max-w-2xl text-base leading-relaxed text-slate-600">
|
|
||||||
Wir richten mit euch die Aufgaben, das Event und den ersten QR-Code ein. Alles ohne Technikstress – Schritt für Schritt.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ol class="mt-8 grid gap-3 md:grid-cols-5">
|
|
||||||
@foreach ($steps as $key => $label)
|
|
||||||
<li class="flex flex-col items-center gap-2">
|
|
||||||
<span
|
|
||||||
@class([
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
|
|
||||||
'bg-rose-500 text-white shadow-lg shadow-rose-300/70' => $step === $key,
|
|
||||||
'bg-white text-rose-500 border border-rose-200' => $step !== $key,
|
|
||||||
])>
|
|
||||||
{{ $loop->iteration }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-medium text-slate-600">
|
|
||||||
{{ $label }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if ($step === 'intro')
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-gradient-to-br from-rose-50 via-white to-sky-50 p-10 shadow-lg shadow-rose-100/50">
|
|
||||||
<div class="space-y-6 text-center">
|
|
||||||
<h2 class="text-3xl font-semibold text-slate-900">So funktioniert die Fotospiel App</h2>
|
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
|
||||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
|
||||||
<h3 class="font-semibold text-slate-900">1 · Aufgaben auswählen</h3>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">Kuratiere Aufgaben, die eure Gäste inspirieren – ohne schon Fotos zu sammeln.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
|
||||||
<h3 class="font-semibold text-slate-900">2 · Event gestalten</h3>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">Name, Datum und Farben bestimmen das Look & Feel eurer Fotostory.</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
|
||||||
<h3 class="font-semibold text-slate-900">3 · QR-Code teilen</h3>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">Euer Einladungslink führt Gäste direkt in die Galerie – kein App-Download notwendig.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
wire:click="start"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
|
||||||
Jetzt loslegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($step === 'packages')
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-10 shadow-xl shadow-rose-100/50">
|
|
||||||
<form wire:submit.prevent="savePackages" class="space-y-6">
|
|
||||||
<div class="space-y-2 text-center">
|
|
||||||
<h2 class="text-3xl font-semibold text-slate-900">Wählt euer erstes Aufgabenpaket</h2>
|
|
||||||
<p class="text-sm text-slate-600">Jedes Paket enthält spielerische Prompts. Ihr könnt später weitere hinzufügen oder eigene Aufgaben erstellen.</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
@foreach ($this->packageList as $package)
|
|
||||||
<label
|
|
||||||
for="package-{{ $package['id'] }}"
|
|
||||||
class="group flex cursor-pointer flex-col gap-3 rounded-2xl border border-rose-100 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="package-{{ $package['id'] }}"
|
|
||||||
type="checkbox"
|
|
||||||
value="{{ $package['id'] }}"
|
|
||||||
wire:model="selectedPackages"
|
|
||||||
class="mt-1 h-4 w-4 rounded border-rose-200 text-rose-500 focus:ring-rose-400" />
|
|
||||||
<div>
|
|
||||||
<p class="text-base font-semibold text-slate-900">{{ $package['name'] }}</p>
|
|
||||||
@if ($package['description'])
|
|
||||||
<p class="mt-1 text-sm text-slate-600">{{ $package['description'] }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@error('selectedPackages')
|
|
||||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
|
||||||
Weiter zu Schritt 2
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($step === 'event')
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-sky-100/50">
|
|
||||||
<form wire:submit.prevent="saveEvent" class="space-y-6">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h2 class="text-3xl font-semibold text-slate-900">Wie heißt euer Event?</h2>
|
|
||||||
<p class="text-sm text-slate-600">Name und Anlass erscheinen in eurer Gästegalerie und auf den Einladungen.</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-5 md:grid-cols-2">
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-slate-700">
|
|
||||||
Event-Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
wire:model.defer="eventName"
|
|
||||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
|
|
||||||
placeholder="z. B. Hochzeit Anna & Lea" />
|
|
||||||
</label>
|
|
||||||
@error('eventName')
|
|
||||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-slate-700">
|
|
||||||
Datum
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
wire:model.defer="eventDate"
|
|
||||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400" />
|
|
||||||
</label>
|
|
||||||
@error('eventDate')
|
|
||||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-semibold text-slate-700">
|
|
||||||
Anlass
|
|
||||||
<select
|
|
||||||
wire:model.defer="eventTypeId"
|
|
||||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
|
|
||||||
<option value="">Bitte wählen</option>
|
|
||||||
@foreach ($this->eventTypeOptions as $id => $label)
|
|
||||||
<option value="{{ $id }}">{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
@error('eventTypeId')
|
|
||||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<button type="button" wire:click="$set('step', 'packages')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
|
||||||
Weiter zu Schritt 3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($step === 'palette')
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-emerald-100/50">
|
|
||||||
<form wire:submit.prevent="savePalette" class="space-y-6">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h2 class="text-3xl font-semibold text-slate-900">Welche Farben spiegeln eure Story?</h2>
|
|
||||||
<p class="text-sm text-slate-600">Wir wenden die Palette auf Karten, QR-Layouts und App-Elemente an. Ihr könnt sie später jederzeit ändern.</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
@foreach ($this->paletteOptions as $value => $data)
|
|
||||||
<label
|
|
||||||
for="palette-{{ $value }}"
|
|
||||||
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="palette-{{ $value }}"
|
|
||||||
type="radio"
|
|
||||||
value="{{ $value }}"
|
|
||||||
wire:model.defer="palette"
|
|
||||||
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
|
|
||||||
<div>
|
|
||||||
<p class="text-base font-semibold text-slate-900">{{ $data['label'] }}</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-600">{{ $data['description'] }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@error('palette')
|
|
||||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<button type="button" wire:click="$set('step', 'event')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
|
||||||
Weiter zu Schritt 4
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($step === 'invite')
|
|
||||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-indigo-100/50">
|
|
||||||
<form wire:submit.prevent="finish" class="space-y-6">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h2 class="text-3xl font-semibold text-slate-900">Wie soll euer Einladungs-Layout aussehen?</h2>
|
|
||||||
<p class="text-sm text-slate-600">Wir generieren sofort einen QR-Code samt PDF/SVG-Downloads.</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
@foreach ($this->layoutOptions as $layout)
|
|
||||||
<label
|
|
||||||
for="layout-{{ $layout['id'] }}"
|
|
||||||
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="layout-{{ $layout['id'] }}"
|
|
||||||
type="radio"
|
|
||||||
value="{{ $layout['id'] }}"
|
|
||||||
wire:model.defer="inviteLayout"
|
|
||||||
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
|
|
||||||
<div>
|
|
||||||
<p class="text-base font-semibold text-slate-900">{{ $layout['name'] }}</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-600">{{ $layout['subtitle'] }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@error('inviteLayout')
|
|
||||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<button type="button" wire:click="$set('step', 'palette')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
@class([
|
|
||||||
'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 text-sm font-semibold text-white shadow-lg transition',
|
|
||||||
'bg-rose-500 shadow-rose-300/60 hover:bg-rose-600' => ! $isProcessing,
|
|
||||||
'bg-rose-400 opacity-70' => $isProcessing,
|
|
||||||
])
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
wire:target="finish">
|
|
||||||
<span wire:loading.remove wire:target="finish">Setup abschließen</span>
|
|
||||||
<span wire:loading wire:target="finish" class="flex items-center gap-2">
|
|
||||||
<x-filament::loading-indicator class="h-4 w-4" />
|
|
||||||
Bitte warten …
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@@ -30,7 +30,7 @@ class RoleBasedLoginTest extends TestCase
|
|||||||
$this->assertEquals('tenant@example.com', Auth::user()->email);
|
$this->assertEquals('tenant@example.com', Auth::user()->email);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_super_admin_redirects_to_admin_panel()
|
public function test_super_admin_redirects_to_super_admin_panel()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'email' => 'super@example.com',
|
'email' => 'super@example.com',
|
||||||
@@ -45,7 +45,7 @@ class RoleBasedLoginTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$response->assertRedirect('/admin');
|
$response->assertRedirect('/super-admin');
|
||||||
$this->assertEquals('super@example.com', Auth::user()->email);
|
$this->assertEquals('super@example.com', Auth::user()->email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user