admin: Management ▸ Bilder — Liste aller Titelbilder mit PM-Link, Nachweis, Kennzeichnung, Bestätigungsstand und KI-Vorab-Check, Filter nach Sichtung/Zustand/Ausgang (Bildkennzeichnung)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 12:46:33 +00:00
parent bcd02f2e8f
commit aaeee24412
4 changed files with 333 additions and 0 deletions

View file

@ -137,6 +137,10 @@
</flux:navlist.group>
<flux:navlist.group :heading="__('Management')" class="grid mb-4">
<flux:navlist.item icon="photo" :href="route('admin.images.index')"
:current="request()->routeIs('admin.images.*')" wire:navigate>
{{ __('Bilder') }}
</flux:navlist.item>
@can('compliance:manage')
<flux:navlist.item icon="queue-list" :href="route('admin.reports.activity')"
:current="request()->routeIs('admin.reports.activity')" wire:navigate>

View file

@ -0,0 +1,257 @@
<?php
use App\Enums\ImagePrecheckCategory;
use App\Enums\ImagePrecheckOutcome;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
/**
* Admin ▸ Management ▸ Bilder (Bildkennzeichnung, Wunsch vom 25.09.2026).
*
* Alle eingereichten Titelbilder mit Link zur Meldung, Nachweis,
* Kennzeichnung, Herkunft, Bestätigungsstand und dem Ergebnis des
* KI-Vorab-Checks (Block K). Entscheidungen fallen weiter auf der PM-Seite.
*/
new #[Layout('components.layouts.app'), Title('Bilder')] class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $precheck = '';
#[Url]
public string $state = '';
public function updatedSearch(): void
{
$this->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'),
];
}
}; ?>
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Bilder') }}</flux:heading>
<flux:subheading>{{ __('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.') }}</flux:subheading>
</div>
<div class="flex flex-wrap gap-2">
<flux:select wire:model.live="state" size="sm">
<flux:select.option value="">{{ __('Alle Bilder') }}</flux:select.option>
<flux:select.option value="review">{{ __('Sichtung offen') }}</flux:select.option>
<flux:select.option value="withheld">{{ __('zurückgehalten') }}</flux:select.option>
<flux:select.option value="unconfirmed">{{ __('Bestätigung fehlt') }}</flux:select.option>
<flux:select.option value="published">{{ __('je veröffentlicht') }}</flux:select.option>
<flux:select.option value="legacy">{{ __('Bestand (ohne Protokoll)') }}</flux:select.option>
</flux:select>
<flux:select wire:model.live="precheck" size="sm">
<flux:select.option value="">{{ __('KI-Check: alle') }}</flux:select.option>
@foreach ($outcomes as $outcome)
<flux:select.option :value="$outcome->value">{{ __('KI-Check: :label', ['label' => $outcome->label()]) }}</flux:select.option>
@endforeach
<flux:select.option value="none">{{ __('KI-Check: nicht geprüft') }}</flux:select.option>
</flux:select>
<flux:input wire:model.live.debounce.400ms="search" size="sm" :placeholder="__('PM-Titel, Nachweis, PM- oder Bild-ID')" class="w-56" />
</div>
</div>
@unless ($precheckEnabled)
<flux:callout variant="warning" icon="information-circle">
{{ __('Der KI-Vorab-Check ist aus (IMAGE_PRECHECK). Neue Bilder werden nicht gesichtet; die Spalte zeigt nur Altbefunde.') }}
</flux:callout>
@endunless
@if ($openReviews > 0)
<flux:callout variant="warning" icon="eye" data-open-reviews>
{{ trans_choice(':count Bild wartet auf eine Sichtung.|:count Bilder warten auf eine Sichtung.', $openReviews, ['count' => $openReviews]) }}
<flux:button size="xs" variant="ghost" wire:click="$set('state', 'review')">{{ __('Anzeigen') }}</flux:button>
</flux:callout>
@endif
<flux:card>
<table class="w-full text-[13px]">
<thead>
<tr class="text-left text-zinc-500">
<th class="py-2 pr-3 font-medium">{{ __('Bild') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Pressemitteilung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Nachweis und Kennzeichnung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Bestätigung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('KI-Vorab-Check') }}</th>
<th class="py-2 font-medium">{{ __('Hochgeladen') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700 align-top">
@forelse ($images as $image)
@php
$thumb = $image->variantUrl('thumb') ?? $image->variantUrl('card') ?? $image->url();
$confirmation = $image->latestRightsConfirmation;
$triggers = $image->precheck['evaluation']['triggers'] ?? [];
@endphp
<tr wire:key="image-{{ $image->id }}" data-image-row="{{ $image->id }}">
<td class="py-2 pr-3">
<div class="flex items-start gap-2">
@if ($thumb)
<img src="{{ $thumb }}" alt="" class="size-14 shrink-0 rounded object-cover bg-zinc-100 dark:bg-zinc-800" loading="lazy">
@else
<div class="size-14 shrink-0 rounded bg-zinc-100 dark:bg-zinc-800"></div>
@endif
<div class="text-xs text-zinc-500">
<div class="font-mono">#{{ $image->id }}</div>
@if ($image->width && $image->height)
<div>{{ $image->width }}×{{ $image->height }}</div>
@endif
@if ($image->isWithheld())
<flux:badge size="sm" color="amber">{{ __('zurückgehalten') }}</flux:badge>
@endif
</div>
</div>
</td>
<td class="py-2 pr-3 max-w-[280px]">
@if ($image->pressRelease)
<a class="font-medium underline underline-offset-2" href="{{ route('admin.press-releases.show', ['id' => $image->press_release_id]) }}" wire:navigate>
{{ \Illuminate\Support\Str::limit($image->pressRelease->title, 70) }}
</a>
<div class="text-xs text-zinc-500">
PM #{{ $image->press_release_id }} · {{ $image->pressRelease->status->label() }}
@if ($image->pressRelease->company)
· {{ $image->pressRelease->company->name }}
@endif
</div>
@else
<span class="text-zinc-500">PM #{{ $image->press_release_id }} ({{ __('gelöscht') }})</span>
@endif
</td>
<td class="py-2 pr-3 max-w-[260px]">
<div>{{ $image->copyright ?: '–' }}</div>
<div class="text-xs text-zinc-500">
@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
</div>
@if ($image->provenance_status)
<div class="text-xs text-zinc-500">{{ __('Herkunft: :status', ['status' => $image->provenance_status->value]) }}</div>
@endif
</td>
<td class="py-2 pr-3 whitespace-nowrap">
@if ($confirmation)
<flux:badge size="sm" color="green">{{ __('bestätigt') }}</flux:badge>
<div class="text-xs text-zinc-500">{{ $confirmation->confirmed_at->format('d.m.Y H:i') }} · {{ $confirmation->legal_text_key }} · {{ $confirmation->channel->value }}</div>
@elseif ($image->awaitsCustomerConfirmation())
<flux:badge size="sm" color="amber">{{ __('wartet auf Kunde') }}</flux:badge>
@elseif ($image->isLegacyRules())
<flux:badge size="sm" color="zinc">{{ __('Bestand') }}</flux:badge>
@else
<flux:badge size="sm" color="red">{{ __('fehlt') }}</flux:badge>
@endif
</td>
<td class="py-2 pr-3 max-w-[300px]">
@if ($image->precheck_outcome)
<flux:badge size="sm" color="{{ match ($image->precheck_outcome) { \App\Enums\ImagePrecheckOutcome::Queue => 'amber', \App\Enums\ImagePrecheckOutcome::Hint => 'sky', \App\Enums\ImagePrecheckOutcome::Error => 'zinc', default => 'green' } }}">
{{ $image->precheck_outcome->label() }}
</flux:badge>
@if ($triggers !== [])
<ul class="mt-1 text-xs text-zinc-500 space-y-0.5">
@foreach ($triggers as $trigger)
<li>{{ ImagePrecheckCategory::tryFrom($trigger['category'] ?? '')?->label() ?? $trigger['category'] }} ({{ number_format((float) ($trigger['confidence'] ?? 0) * 100, 0) }} %)</li>
@endforeach
</ul>
@endif
@if ($image->awaitsPrecheckReview())
<div class="mt-1 text-xs font-medium text-amber-700 dark:text-amber-300">{{ __('Sichtung offen') }}</div>
@elseif ($image->precheck_reviewed_at)
<div class="mt-1 text-xs text-zinc-500">
{{ __('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]) }}
</div>
@endif
@if (! empty($image->precheck['model']))
<div class="mt-1 text-[11px] text-zinc-400">{{ $image->precheck['model'] }}</div>
@endif
@else
<span class="text-xs text-zinc-500">{{ __('nicht geprüft') }}</span>
@endif
</td>
<td class="py-2 whitespace-nowrap text-xs text-zinc-500">
{{ $image->created_at?->format('d.m.Y H:i') }}
@if ($image->published_ever_at)
<div>{{ __('veröffentlicht') }}</div>
@endif
</td>
</tr>
@empty
<tr><td colspan="6" class="py-6 text-center text-zinc-500">{{ __('Keine Bilder gefunden.') }}</td></tr>
@endforelse
</tbody>
</table>
<div class="mt-4">{{ $images->links() }}</div>
</flux:card>
</div>