diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index f1e7fb1a..3c94f11d 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -137,6 +137,10 @@ + + {{ __('Bilder') }} + @can('compliance:manage') diff --git a/resources/views/livewire/admin/images/index.blade.php b/resources/views/livewire/admin/images/index.blade.php new file mode 100644 index 00000000..88c142a7 --- /dev/null +++ b/resources/views/livewire/admin/images/index.blade.php @@ -0,0 +1,257 @@ +resetPage(); + } + + public function updatedPrecheck(): void + { + $this->resetPage(); + } + + public function updatedState(): void + { + $this->resetPage(); + } + + public function with(): array + { + $query = PressReleaseImage::query() + ->with([ + 'pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->select(['id', 'title', 'status', 'portal', 'company_id', 'published_at'])->with('company:id,name'), + 'latestRightsConfirmation', + 'precheckReviewer:id,name', + ]) + ->orderByDesc('id'); + + if ($this->search !== '') { + $term = trim($this->search); + $query->where(function ($q) use ($term): void { + if (ctype_digit($term)) { + $q->where('id', (int) $term)->orWhere('press_release_id', (int) $term); + } + + $q->orWhereHas('pressRelease', fn ($pr) => $pr->withoutGlobalScope(PortalScope::class)->where('title', 'like', '%'.$term.'%')) + ->orWhere('copyright', 'like', '%'.$term.'%'); + }); + } + + if ($this->precheck !== '') { + $this->precheck === 'none' + ? $query->whereNull('precheck_outcome') + : $query->where('precheck_outcome', $this->precheck); + } + + match ($this->state) { + 'review' => $query->awaitingPrecheckReview(), + 'withheld' => $query->whereNotNull('withheld_at'), + 'unconfirmed' => $query->whereNotNull('rules_version')->whereNull('rights_confirmed_at'), + 'legacy' => $query->whereNull('rules_version'), + 'published' => $query->whereNotNull('published_ever_at'), + default => null, + }; + + return [ + 'images' => $query->paginate(40), + 'outcomes' => ImagePrecheckOutcome::cases(), + 'openReviews' => PressReleaseImage::query()->awaitingPrecheckReview()->count(), + 'precheckEnabled' => (bool) config('images.precheck.enabled'), + ]; + } +}; ?> + +
+
+
+ {{ __('Bilder') }} + {{ __('Alle eingereichten Titelbilder mit Nachweis, Kennzeichnung, Bestätigungsstand und dem Ergebnis des KI-Vorab-Checks. Entscheidungen zur Sichtung und Korrekturen laufen auf der Seite der Meldung.') }} +
+
+ + {{ __('Alle Bilder') }} + {{ __('Sichtung offen') }} + {{ __('zurückgehalten') }} + {{ __('Bestätigung fehlt') }} + {{ __('je veröffentlicht') }} + {{ __('Bestand (ohne Protokoll)') }} + + + {{ __('KI-Check: alle') }} + @foreach ($outcomes as $outcome) + {{ __('KI-Check: :label', ['label' => $outcome->label()]) }} + @endforeach + {{ __('KI-Check: nicht geprüft') }} + + +
+
+ + @unless ($precheckEnabled) + + {{ __('Der KI-Vorab-Check ist aus (IMAGE_PRECHECK). Neue Bilder werden nicht gesichtet; die Spalte zeigt nur Altbefunde.') }} + + @endunless + + @if ($openReviews > 0) + + {{ trans_choice(':count Bild wartet auf eine Sichtung.|:count Bilder warten auf eine Sichtung.', $openReviews, ['count' => $openReviews]) }} + {{ __('Anzeigen') }} + + @endif + + + + + + + + + + + + + + + @forelse ($images as $image) + @php + $thumb = $image->variantUrl('thumb') ?? $image->variantUrl('card') ?? $image->url(); + $confirmation = $image->latestRightsConfirmation; + $triggers = $image->precheck['evaluation']['triggers'] ?? []; + @endphp + + + + + + + + + @empty + + @endforelse + +
{{ __('Bild') }}{{ __('Pressemitteilung') }}{{ __('Nachweis und Kennzeichnung') }}{{ __('Bestätigung') }}{{ __('KI-Vorab-Check') }}{{ __('Hochgeladen') }}
+
+ @if ($thumb) + + @else +
+ @endif +
+
#{{ $image->id }}
+ @if ($image->width && $image->height) +
{{ $image->width }}×{{ $image->height }}
+ @endif + @if ($image->isWithheld()) + {{ __('zurückgehalten') }} + @endif +
+
+
+ @if ($image->pressRelease) + + {{ \Illuminate\Support\Str::limit($image->pressRelease->title, 70) }} + +
+ PM #{{ $image->press_release_id }} · {{ $image->pressRelease->status->label() }} + @if ($image->pressRelease->company) + · {{ $image->pressRelease->company->name }} + @endif +
+ @else + PM #{{ $image->press_release_id }} ({{ __('gelöscht') }}) + @endif +
+
{{ $image->copyright ?: '–' }}
+
+ @if ($image->ai_declaration) + {{ $image->ai_declaration->label() }} + @if ($image->ai_declaration_source) + ({{ $image->ai_declaration_source->publicLabel() }}) + @endif + @if ($image->ai_declaration_locked) + · {{ __('gesperrt') }} + @endif + @else + {{ __('keine Kennzeichnung') }} + @endif +
+ @if ($image->provenance_status) +
{{ __('Herkunft: :status', ['status' => $image->provenance_status->value]) }}
+ @endif +
+ @if ($confirmation) + {{ __('bestätigt') }} +
{{ $confirmation->confirmed_at->format('d.m.Y H:i') }} · {{ $confirmation->legal_text_key }} · {{ $confirmation->channel->value }}
+ @elseif ($image->awaitsCustomerConfirmation()) + {{ __('wartet auf Kunde') }} + @elseif ($image->isLegacyRules()) + {{ __('Bestand') }} + @else + {{ __('fehlt') }} + @endif +
+ @if ($image->precheck_outcome) + + {{ $image->precheck_outcome->label() }} + + @if ($triggers !== []) +
    + @foreach ($triggers as $trigger) +
  • {{ ImagePrecheckCategory::tryFrom($trigger['category'] ?? '')?->label() ?? $trigger['category'] }} ({{ number_format((float) ($trigger['confidence'] ?? 0) * 100, 0) }} %)
  • + @endforeach +
+ @endif + @if ($image->awaitsPrecheckReview()) +
{{ __('Sichtung offen') }}
+ @elseif ($image->precheck_reviewed_at) +
+ {{ __('Entschieden :date von :name: :note', ['date' => $image->precheck_reviewed_at->format('d.m.Y H:i'), 'name' => $image->precheckReviewer?->name ?? '–', 'note' => $image->precheck_review_note]) }} +
+ @endif + @if (! empty($image->precheck['model'])) +
{{ $image->precheck['model'] }}
+ @endif + @else + {{ __('nicht geprüft') }} + @endif +
+ {{ $image->created_at?->format('d.m.Y H:i') }} + @if ($image->published_ever_at) +
{{ __('veröffentlicht') }}
+ @endif +
{{ __('Keine Bilder gefunden.') }}
+
{{ $images->links() }}
+
+
diff --git a/routes/admin.php b/routes/admin.php index efeec522..e15dae31 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -130,6 +130,9 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens Volt::route('admin/roles/create', 'admin.roles.create')->name('admin.roles.create'); Volt::route('admin/roles/{id}/edit', 'admin.roles.edit')->name('admin.roles.edit'); + // Bilder (Bildkennzeichnung): alle Titelbilder mit Nachweis, Bestätigung und KI-Vorab-Check. + Volt::route('admin/images', 'admin.images.index')->name('admin.images.index'); + // Beweisarchiv (Bildkennzeichnung Block F): Liste, Legal Hold, Öffnen/Export mit Zweck. Route::middleware('can:compliance:manage')->group(function (): void { Volt::route('admin/archive', 'admin.archive.index')->name('admin.archive.index'); diff --git a/tests/Feature/Admin/AdminImagesIndexTest.php b/tests/Feature/Admin/AdminImagesIndexTest.php new file mode 100644 index 00000000..2ff61266 --- /dev/null +++ b/tests/Feature/Admin/AdminImagesIndexTest.php @@ -0,0 +1,69 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function imagesIndexAdmin(): User +{ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + return $admin; +} + +test('the images list shows every title image with press release link, confirmation state and precheck result', function () { + /** @var TestCase $this */ + $this->actingAs(imagesIndexAdmin()); + + $pr = PressRelease::factory()->create(['title' => 'Bildliste Testmeldung']); + $queued = PressReleaseImage::factory()->rulesV2()->create([ + 'press_release_id' => $pr->id, + 'copyright' => 'Foto: Beispiel AG', + 'precheck_outcome' => ImagePrecheckOutcome::Queue, + 'precheck_review_required_at' => now(), + 'withheld_at' => now(), + 'precheck' => ['model' => 'test-model', 'evaluation' => ['triggers' => [['category' => 'screenshot_or_collage', 'confidence' => 0.7, 'outcome' => 'queue', 'note' => null]]]], + ]); + $legacy = PressReleaseImage::factory()->legacy()->create(['press_release_id' => PressRelease::factory()->create()->id]); + + $this->get(route('admin.images.index'))->assertSuccessful(); + + LivewireVolt::test('admin.images.index') + ->assertSee('Bildliste Testmeldung') + ->assertSee(route('admin.press-releases.show', ['id' => $pr->id])) + ->assertSee('Foto: Beispiel AG') + ->assertSee('Screenshot oder Collage') + ->assertSee('Sichtung offen') + ->assertSee('Bestand') + ->assertSeeHtml('data-image-row="'.$queued->id.'"') + ->assertSeeHtml('data-image-row="'.$legacy->id.'"') + ->set('state', 'review') + ->assertSeeHtml('data-image-row="'.$queued->id.'"') + ->assertDontSeeHtml('data-image-row="'.$legacy->id.'"') + ->set('state', '') + ->set('precheck', 'none') + ->assertDontSeeHtml('data-image-row="'.$queued->id.'"') + ->assertSeeHtml('data-image-row="'.$legacy->id.'"') + ->set('precheck', '') + ->set('search', 'Bildliste') + ->assertSeeHtml('data-image-row="'.$queued->id.'"') + ->assertDontSeeHtml('data-image-row="'.$legacy->id.'"'); +}); + +test('customers cannot open the images list', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['is_active' => true]); + $user->assignRole('customer'); + + $this->actingAs($user)->get(route('admin.images.index'))->assertForbidden(); +});