> */ private static array $usageCache = []; public static function configure(Schema $schema): Schema { return $schema->components([ Section::make(__('admin.tenants.sections.lifecycle')) ->columns(3) ->schema([ TextEntry::make('access_status') ->label(__('admin.tenants.fields.access_status')) ->badge() ->color(fn (Tenant $record): string => static::accessStatusTone($record)) ->state(fn (Tenant $record): string => static::accessStatusLabel($record)), IconEntry::make('is_active') ->label(__('admin.tenants.fields.is_active')) ->boolean(), IconEntry::make('is_suspended') ->label(__('admin.tenants.fields.is_suspended')) ->boolean(), TextEntry::make('subscription_expires_at') ->label(__('admin.tenants.fields.subscription_expires_at')) ->dateTime() ->placeholder('—'), TextEntry::make('grace_period_ends_at') ->label(__('admin.tenants.fields.grace_period_ends_at')) ->dateTime() ->placeholder('—'), TextEntry::make('pending_deletion_at') ->label(__('admin.tenants.fields.pending_deletion_at')) ->dateTime() ->placeholder('—'), TextEntry::make('deletion_warning_sent_at') ->label(__('admin.tenants.fields.deletion_warning_sent_at')) ->dateTime() ->placeholder('—'), TextEntry::make('anonymized_at') ->label(__('admin.tenants.fields.anonymized_at')) ->dateTime() ->placeholder('—'), ]), Section::make(__('admin.tenants.sections.limits')) ->columns(3) ->schema([ TextEntry::make('max_photos_per_event') ->label(__('admin.tenants.fields.max_photos_per_event')) ->state(fn (Tenant $record): string => static::formatLimitValue($record->max_photos_per_event)), TextEntry::make('max_storage_mb') ->label(__('admin.tenants.fields.max_storage_mb')) ->state(fn (Tenant $record): string => static::formatLimitValue($record->max_storage_mb, 'MB')), TextEntry::make('storage_used_mb') ->label(__('admin.tenants.fields.storage_used_mb')) ->state(fn (Tenant $record): string => static::formatStorageValue($record)['used']), TextEntry::make('storage_remaining_mb') ->label(__('admin.tenants.fields.storage_remaining_mb')) ->state(fn (Tenant $record): string => static::formatStorageValue($record)['remaining']), TextEntry::make('storage_usage_percent') ->label(__('admin.tenants.fields.storage_usage_percent')) ->badge() ->color(fn (Tenant $record): string => static::storageUsageTone($record)) ->state(fn (Tenant $record): string => static::formatStorageValue($record)['percentage']), ]), Section::make(__('admin.tenants.sections.timeline')) ->schema([ RepeatableEntry::make('lifecycle_timeline') ->label(__('admin.tenants.sections.timeline')) ->state(fn (Tenant $record) => static::buildTimeline($record)) ->schema([ TextEntry::make('title') ->label(__('admin.tenants.timeline.title')) ->columnSpanFull(), TextEntry::make('details') ->label(__('admin.tenants.timeline.details')) ->columnSpanFull() ->placeholder('—'), TextEntry::make('tone') ->label(__('admin.tenants.timeline.status')) ->badge() ->color(fn (?string $state) => $state ?? 'gray') ->formatStateUsing(fn (?string $state) => $state ? __('admin.tenants.timeline.tone.'.$state) : __('admin.tenants.timeline.tone.muted')), TextEntry::make('occurred_at') ->label(__('admin.tenants.timeline.occurred_at')) ->dateTime(), ]) ->columns(2), ]), ]); } private static function accessStatusLabel(Tenant $tenant): string { if ($tenant->anonymized_at) { return __('admin.tenants.status.anonymized'); } if ($tenant->is_suspended) { return __('admin.tenants.status.suspended'); } if (! $tenant->is_active) { return __('admin.tenants.status.inactive'); } if ($tenant->subscription_expires_at && $tenant->subscription_expires_at->isPast()) { return $tenant->isInGracePeriod() ? __('admin.tenants.status.grace') : __('admin.tenants.status.expired'); } return __('admin.tenants.status.active'); } private static function accessStatusTone(Tenant $tenant): string { if ($tenant->anonymized_at) { return 'danger'; } if ($tenant->is_suspended) { return 'warning'; } if (! $tenant->is_active) { return 'danger'; } if ($tenant->subscription_expires_at && $tenant->subscription_expires_at->isPast()) { return $tenant->isInGracePeriod() ? 'warning' : 'danger'; } return 'success'; } private static function formatLimitValue(?int $value, ?string $suffix = null): string { if (! $value || $value <= 0) { return __('admin.tenants.limits.unlimited'); } return $suffix ? $value.' '.$suffix : (string) $value; } /** * @return array{used: string, remaining: string, percentage: string} */ private static function formatStorageValue(Tenant $tenant): array { $summary = static::storageSummary($tenant); $used = $summary['used_mb'] !== null ? $summary['used_mb'].' MB' : '—'; $remaining = $summary['remaining_mb'] !== null ? $summary['remaining_mb'].' MB' : __('admin.tenants.limits.unlimited'); $percentage = $summary['percentage'] !== null ? $summary['percentage'].'%' : __('admin.tenants.limits.unlimited'); return [ 'used' => $used, 'remaining' => $remaining, 'percentage' => $percentage, ]; } private static function storageUsageTone(Tenant $tenant): string { $summary = static::storageSummary($tenant); if ($summary['percentage'] === null) { return 'gray'; } if ($summary['percentage'] >= 95) { return 'danger'; } if ($summary['percentage'] >= 80) { return 'warning'; } return 'success'; } /** * @return array */ private static function storageSummary(Tenant $tenant): array { if (isset(static::$usageCache[$tenant->getKey()])) { return static::$usageCache[$tenant->getKey()]; } $summary = app(TenantUsageService::class)->storageSummary($tenant); static::$usageCache[$tenant->getKey()] = $summary; return $summary; } private static function buildTimeline(Tenant $tenant): array { $events = [ static::timelineEntry( __('admin.tenants.timeline.created'), __('admin.tenants.timeline.created_details'), $tenant->created_at, 'success' ), ]; if ($tenant->last_activity_at) { $events[] = static::timelineEntry( __('admin.tenants.timeline.last_activity'), __('admin.tenants.timeline.last_activity_details'), $tenant->last_activity_at, 'info' ); } if ($tenant->deletion_warning_sent_at) { $events[] = static::timelineEntry( __('admin.tenants.timeline.deletion_warning'), __('admin.tenants.timeline.deletion_warning_details'), $tenant->deletion_warning_sent_at, 'warning' ); } if ($tenant->pending_deletion_at) { $events[] = static::timelineEntry( __('admin.tenants.timeline.deletion_scheduled'), __('admin.tenants.timeline.deletion_scheduled_details'), $tenant->pending_deletion_at, 'warning' ); } if ($tenant->anonymized_at) { $events[] = static::timelineEntry( __('admin.tenants.timeline.anonymized'), __('admin.tenants.timeline.anonymized_details'), $tenant->anonymized_at, 'danger' ); } $lifecycleEvents = $tenant->lifecycleEvents() ->with('actor') ->latest('occurred_at') ->limit(25) ->get(); foreach ($lifecycleEvents as $event) { $events[] = static::timelineEntry( static::lifecycleTitle($event), static::lifecycleDetails($event), $event->occurred_at ?? $event->created_at, static::lifecycleTone($event) ); } $logs = $tenant->notificationLogs() ->latest('sent_at') ->limit(10) ->get(); foreach ($logs as $log) { $status = $log->status === 'failed' ? 'danger' : 'info'; $eventTitle = $log->status === 'failed' ? __('admin.tenants.timeline.notification_failed') : __('admin.tenants.timeline.notification_sent'); $details = collect([ Str::headline($log->type), $log->channel ? Str::upper($log->channel) : null, $log->recipient, $log->failure_reason ? 'reason: '.$log->failure_reason : null, ])->filter()->implode(' - '); $events[] = static::timelineEntry( $eventTitle, $details, $log->sent_at ?? $log->failed_at ?? $log->created_at, $status ); } return collect($events) ->filter(fn (array $event) => $event['occurred_at'] !== null) ->sortByDesc('occurred_at') ->values() ->all(); } private static function lifecycleTitle(TenantLifecycleEvent $event): string { $key = 'admin.tenants.timeline.events.'.$event->type; return __($key) !== $key ? __($key) : Str::headline($event->type); } private static function lifecycleTone(TenantLifecycleEvent $event): string { return match ($event->type) { 'activated' => 'success', 'unsuspended' => 'success', 'deactivated' => 'danger', 'suspended' => 'warning', 'anonymize_requested' => 'danger', 'deletion_scheduled' => 'warning', 'deletion_cancelled' => 'info', 'grace_period_set' => 'warning', 'grace_period_cleared' => 'info', 'limits_updated' => 'info', 'subscription_expires_at_updated' => 'info', default => 'info', }; } private static function lifecycleDetails(TenantLifecycleEvent $event): ?string { $payload = is_array($event->payload) ? $event->payload : []; $details = []; if (! empty($payload['note'])) { $details[] = (string) $payload['note']; } if (isset($payload['grace_period_ends_at'])) { $details[] = __('admin.tenants.timeline.grace_period_until', [ 'date' => (string) $payload['grace_period_ends_at'], ]); } if (isset($payload['before'], $payload['after']) && is_array($payload['before']) && is_array($payload['after'])) { foreach (['max_photos_per_event', 'max_storage_mb', 'subscription_expires_at'] as $field) { if (! array_key_exists($field, $payload['before']) && ! array_key_exists($field, $payload['after'])) { continue; } $before = $payload['before'][$field] ?? '—'; $after = $payload['after'][$field] ?? '—'; $label = __('admin.tenants.fields.'.$field); $details[] = $label.': '.$before.' -> '.$after; } } if ($event->actor) { $details[] = __('admin.tenants.timeline.by', [ 'name' => $event->actor->getFilamentName(), ]); } return empty($details) ? null : implode(' - ', $details); } private static function timelineEntry(string $title, ?string $details, $occurredAt, string $tone): array { return [ 'title' => $title, 'details' => $details, 'occurred_at' => $occurredAt, 'tone' => $tone, ]; } }