Refine admin risk workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m3s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m6s

This commit is contained in:
AzuTear
2026-06-26 18:49:01 +02:00
parent ca69cdb0de
commit 90795759f3
+432 -100
View File
@@ -1,20 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { FileClock, ShieldAlert, SlidersHorizontal } from '@lucide/vue' import { ref } from 'vue'
import { computed, ref } from 'vue' import {
AlertTriangle,
CheckCircle2,
Clock3,
ExternalLink,
FileClock,
ListChecks,
RefreshCw,
Search,
ShieldAlert,
SlidersHorizontal,
Trash2,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminRiskDecisionPanel from '../../components/admin/AdminRiskDecisionPanel.vue'
import AdminRiskHistorySection from '../../components/admin/AdminRiskHistorySection.vue' import AdminRiskHistorySection from '../../components/admin/AdminRiskHistorySection.vue'
import AdminRiskOverviewBoard from '../../components/admin/AdminRiskOverviewBoard.vue'
import AdminRiskQueueList from '../../components/admin/AdminRiskQueueList.vue'
import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue' import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue'
import { useAdminRiskManager } from '../../components/admin/useAdminRiskManager' import {
riskAgeLabel,
riskSeverityClass,
useAdminRiskManager,
} from '../../components/admin/useAdminRiskManager'
import { watchAdminToast } from '../../composables/useAdminToast' import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue' import Modal from '../../components/ui/Modal.vue'
type RiskModalKey = 'rules' | 'history'
const { const {
riskSaving, riskSaving,
riskLoading, riskLoading,
@@ -65,28 +79,23 @@ const {
saveRiskRules, saveRiskRules,
} = useAdminRiskManager() } = useAdminRiskManager()
const activeRiskModal = ref<RiskModalKey | null>(null)
const riskModalSummaries = computed(() => [
{
key: 'rules' as const,
eyebrow: 'Regeln',
title: 'Regel-Editor',
description: 'Thresholds, Zeitfenster und Severity zentral anpassen.',
metric: riskRulesLoading.value ? 'lädt' : `${riskRules.value.length} Regeln`,
icon: SlidersHorizontal,
},
{
key: 'history' as const,
eyebrow: 'Protokoll',
title: 'Entscheidungsprotokoll',
description: 'Entschiedene Hinweise prüfen und bei Bedarf wieder öffnen.',
metric: `${riskHistory.value.length} Einträge`,
icon: FileClock,
},
])
const currentRiskModal = computed(() => riskModalSummaries.value.find((item) => item.key === activeRiskModal.value) ?? null)
watchAdminToast(adminMessage, adminError) watchAdminToast(adminMessage, adminError)
const activeModal = ref<'rules' | 'history' | null>(null)
function flagLeftBorderClass(severity: string) {
const s = severity.toLowerCase()
if (s === 'high') return 'border-l-rose-400'
if (s === 'medium') return 'border-l-amber-400'
return 'border-l-sky-300'
}
function statToneClass(tone: string) {
if (tone === 'danger') return 'text-rose-700 bg-rose-50 border-rose-100'
if (tone === 'warning') return 'text-amber-700 bg-amber-50 border-amber-100'
if (tone === 'ok') return 'text-emerald-700 bg-emerald-50 border-emerald-100'
return 'text-violet-700 bg-violet-50 border-violet-100'
}
</script> </script>
<template> <template>
@@ -97,98 +106,421 @@ watchAdminToast(adminMessage, adminError)
:icon="ShieldAlert" :icon="ShieldAlert"
/> />
<Card class="overflow-hidden"> <!-- Filter + Action bar -->
<AdminRiskOverviewBoard <Card class="p-4">
v-model:risk-filter="riskFilter" <div class="flex flex-col gap-3 xl:flex-row xl:items-center">
v-model:severity-filter="severityFilter" <label class="relative flex-1">
:filtered-count="filteredRiskFlags.length" <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
:total-open="riskFlags.length" <input
:loaded-label="riskLoadedLabel" v-model="riskFilter"
:loading="riskLoading" type="text"
:stats="riskStats" class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
:severity-filters="severityFilters" placeholder="Typ, User, IP, Summary oder Metadaten filtern"
@refresh="loadRiskFlags"
/> />
</label>
<div class="grid gap-5 p-6 xl:grid-cols-[minmax(300px,0.78fr)_minmax(0,1.22fr)]"> <div class="flex flex-wrap gap-1.5">
<AdminRiskQueueList
:risk-flags="filteredRiskFlags"
:total-open="riskFlags.length"
:selected-risk-flag-id="selectedRiskFlagId"
:page="queuePage"
:page-label="queuePageLabel"
:has-previous="queueHasPrevious"
:has-more="queueHasMore"
:selected-bulk-risk-flag-ids="selectedBulkRiskFlagIds"
:bulk-review-note="bulkReviewNote"
:bulk-saving="bulkSaving"
:can-bulk-resolve="canBulkResolve"
@select="selectedRiskFlagId = $event"
@page="setQueuePage"
@toggle-bulk="toggleBulkRiskFlag"
@select-visible-low="selectVisibleLowRiskFlags"
@clear-bulk="clearBulkSelection"
@update:bulk-review-note="bulkReviewNote = $event"
@bulk-decide="bulkResolveRiskFlags"
/>
<AdminRiskDecisionPanel
v-model:decision-note="selectedDecisionNote"
:risk-flag="selectedRiskFlag"
:metadata-items="selectedRiskMetadata"
:decision-note-length="selectedDecisionNoteLength"
:decision-ready="selectedDecisionReady"
:saving="riskSaving"
@decide="updateRiskFlagStatus"
/>
</div>
<section class="border-t border-violet-100 bg-violet-50/25 p-6" aria-labelledby="risk-tools-title">
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Werkzeuge</p>
<h2 id="risk-tools-title" class="text-lg font-bold text-slate-900">Regeln und Protokolle</h2>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
<button <button
v-for="item in riskModalSummaries" v-for="filter in severityFilters"
:key="item.key" :key="filter.key"
type="button" type="button"
class="group flex min-h-[118px] items-start gap-4 rounded-[24px] border border-violet-100 bg-white/88 p-5 text-left shadow-[0_14px_36px_rgba(124,92,255,0.07)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-white hover:shadow-[0_18px_44px_rgba(124,92,255,0.12)]" class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
@click="activeRiskModal = item.key" :class="severityFilter === filter.key
? 'border-violet-200 bg-violet-100 text-violet-800'
: 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="severityFilter = filter.key"
> >
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700 transition group-hover:bg-violet-50"> {{ filter.label }} · {{ filter.count }}
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.eyebrow }}</span>
<span class="mt-1 block text-lg font-bold text-slate-900">{{ item.title }}</span>
<span class="mt-2 block text-sm leading-6 text-slate-500">{{ item.description }}</span>
</span>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700">{{ item.metric }}</span>
</button> </button>
</div> </div>
</section>
<div class="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-200 bg-violet-50 text-violet-700 shadow-none hover:bg-violet-100"
:disabled="riskLoading"
@click="loadRiskFlags"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': riskLoading }" />
{{ riskLoading ? 'Lädt …' : 'Aktualisieren' }}
</Button>
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100 shadow-none hover:bg-violet-50"
@click="activeModal = 'rules'"
>
<SlidersHorizontal class="h-4 w-4" />
Regeln
<span class="rounded-full bg-violet-100 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">
{{ riskRules.length }}
</span>
</Button>
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100 shadow-none hover:bg-violet-50"
@click="activeModal = 'history'"
>
<FileClock class="h-4 w-4" />
Protokoll
<span class="rounded-full bg-violet-100 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">
{{ riskHistory.length }}
</span>
</Button>
</div>
</div>
</Card> </Card>
<!-- Stat cards -->
<section class="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Card
v-for="stat in riskStats"
:key="stat.label"
class="flex items-center gap-4 p-5"
>
<span
class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border text-lg font-black"
:class="statToneClass(stat.tone)"
>
{{ stat.value }}
</span>
<span class="text-sm font-semibold text-slate-600">{{ stat.label }}</span>
</Card>
</section>
<!-- Main: Queue (left) + Decision Panel (right) -->
<section class="grid gap-6 xl:grid-cols-[minmax(320px,0.78fr)_minmax(0,1.22fr)]">
<!-- Queue Card -->
<Card class="flex flex-col overflow-hidden">
<!-- Queue header -->
<div class="flex items-center justify-between gap-3 border-b border-violet-100 px-5 py-3.5">
<div>
<h2 class="text-base font-bold text-slate-900">Offene Queue</h2>
<p class="text-xs text-slate-400">{{ riskLoadedLabel }}</p>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
{{ queuePageLabel }}
</span>
</div>
<!-- Flag list -->
<div class="flex-1 divide-y divide-violet-50 overflow-y-auto xl:max-h-[640px]">
<button
v-for="flag in filteredRiskFlags"
:key="flag.id"
type="button"
class="group relative w-full border-l-[3px] p-4 text-left transition"
:class="[
flagLeftBorderClass(flag.severity),
selectedRiskFlagId === flag.id
? 'bg-[linear-gradient(135deg,#ece2ff20,#fff2dd10)]'
: 'hover:bg-violet-50/30',
]"
@click="selectedRiskFlagId = flag.id"
>
<!-- Bulk checkbox only for Low severity, tucked to top-right -->
<label
v-if="flag.severity.toLowerCase() === 'low'"
class="absolute right-4 top-4 z-10 cursor-pointer"
:title="'Für Bulk auswählen'"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 accent-violet-600"
:checked="selectedBulkRiskFlagIds.includes(flag.id)"
@change="toggleBulkRiskFlag(flag.id)"
/>
</label>
<div class="flex flex-wrap items-center gap-1.5 pr-6">
<span
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold"
:class="riskSeverityClass(flag.severity)"
>
<AlertTriangle class="h-3 w-3" />
{{ flag.severity }}
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2 py-0.5 text-[10px] font-semibold text-slate-500">
<Clock3 class="h-3 w-3" />
{{ riskAgeLabel(flag) }}
</span>
<span class="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-700">
{{ flag.type }}
</span>
</div>
<h3 class="mt-2 line-clamp-2 pr-6 text-sm font-semibold text-slate-900">{{ flag.summary }}</h3>
<p class="mt-1 truncate text-xs text-slate-400">
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.source }}
</p>
</button>
<p
v-if="riskFlags.length === 0"
class="px-5 py-10 text-center text-sm text-slate-400"
>
Keine offenen Risikohinweise vorhanden.
</p>
<p
v-else-if="filteredRiskFlags.length === 0"
class="px-5 py-10 text-center text-sm text-slate-400"
>
Keine Hinweise passen zum aktuellen Filter.
</p>
</div>
<!-- Pagination footer -->
<div class="flex items-center justify-between gap-2 border-t border-violet-100 bg-white px-3 py-2">
<Button
variant="ghost"
size="sm"
class="border border-violet-100 shadow-none"
:disabled="!queueHasPrevious"
@click="setQueuePage(queuePage - 1)"
>
Zurück
</Button>
<span class="text-xs font-semibold text-slate-400">{{ queuePageLabel }}</span>
<Button
variant="ghost"
size="sm"
class="border border-violet-100 shadow-none"
:disabled="!queueHasMore"
@click="setQueuePage(queuePage + 1)"
>
Weiter
</Button>
</div>
<!-- Bulk tray slides in when items are selected -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-64"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-64"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="selectedBulkRiskFlagIds.length > 0"
class="overflow-hidden border-t border-amber-100 bg-amber-50/80 p-4"
>
<div class="mb-3 flex items-center justify-between gap-3">
<span class="text-xs font-bold text-amber-800">
{{ selectedBulkRiskFlagIds.length }} Low-Hinweise ausgewählt
</span>
<div class="flex gap-1.5">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg border border-amber-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-amber-700 transition hover:bg-amber-50"
@click="selectVisibleLowRiskFlags"
>
<ListChecks class="h-3.5 w-3.5" />
Alle Low
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg border border-amber-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-amber-700 transition hover:bg-amber-50"
@click="clearBulkSelection"
>
<X class="h-3.5 w-3.5" />
Leeren
</button>
</div>
</div>
<input
v-model="bulkReviewNote"
class="mb-3 h-10 w-full rounded-xl border border-amber-200 bg-white px-3 text-sm outline-none transition focus:border-amber-400 focus:ring-4 focus:ring-amber-100/60"
placeholder="Bulk-Notiz eingeben (mind. 3 Zeichen)"
/>
<div class="flex gap-2">
<Button
size="sm"
variant="secondary"
class="flex-1 gap-2"
:disabled="!canBulkResolve || bulkSaving"
@click="bulkResolveRiskFlags('dismissed')"
>
<Trash2 class="h-3.5 w-3.5" />
{{ bulkSaving ? '…' : 'Alle verwerfen' }}
</Button>
<Button
size="sm"
class="flex-1 gap-2"
:disabled="!canBulkResolve || bulkSaving"
@click="bulkResolveRiskFlags('resolved')"
>
<CheckCircle2 class="h-3.5 w-3.5" />
{{ bulkSaving ? '…' : 'Alle erledigen' }}
</Button>
</div>
</div>
</Transition>
</Card>
<!-- Decision Panel -->
<div>
<Card v-if="selectedRiskFlag" class="overflow-hidden">
<!-- Gradient header -->
<div class="border-b border-violet-100 bg-[linear-gradient(135deg,#f7f2ff,#fff7ed)] px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold"
:class="riskSeverityClass(selectedRiskFlag.severity)"
>
<AlertTriangle class="h-3.5 w-3.5" />
{{ selectedRiskFlag.severity }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
<Clock3 class="h-3.5 w-3.5" />
{{ riskAgeLabel(selectedRiskFlag) }}
</span>
<span class="rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-800">
{{ selectedRiskFlag.source }} · {{ selectedRiskFlag.type }}
</span>
</div>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-violet-700">
#{{ selectedRiskFlag.id }}
</span>
</div>
<h2 class="mt-4 text-xl font-bold text-slate-900">{{ selectedRiskFlag.summary }}</h2>
<p class="mt-2 text-sm text-slate-500">
{{ selectedRiskFlag.twitchUserId || 'unbekannter User' }}
· {{ selectedRiskFlag.createdFromIp }}
· {{ new Date(selectedRiskFlag.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="space-y-5 p-5">
<!-- Metadata + Entity links -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Metadata -->
<div>
<p class="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">Metadaten</p>
<dl v-if="selectedRiskMetadata.length" class="space-y-2">
<div
v-for="item in selectedRiskMetadata"
:key="item.key"
class="rounded-xl border border-violet-100 bg-violet-50/50 px-3 py-2"
>
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{{ item.key }}</dt>
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
</div>
</dl>
<p
v-else
class="rounded-xl border border-dashed border-violet-100 px-3 py-5 text-sm text-slate-400"
>
Keine zusätzlichen Metadaten.
</p>
</div>
<!-- Entity links -->
<div>
<p class="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">Betroffene Bereiche</p>
<div v-if="selectedRiskFlag.entityLinks.length" class="flex flex-wrap gap-2">
<RouterLink
v-for="link in selectedRiskFlag.entityLinks"
:key="`${link.entityType}-${link.entityId}`"
:to="link.to"
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-sm font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
>
<ExternalLink class="h-4 w-4" />
{{ link.label }}
</RouterLink>
</div>
<p
v-else
class="rounded-xl border border-dashed border-violet-100 px-3 py-5 text-sm text-slate-400"
>
Kein direkter Zielbereich hinterlegt.
</p>
</div>
</div>
<!-- Decision area -->
<div class="rounded-2xl border border-violet-100 bg-violet-50/30 p-4">
<label class="block">
<span class="text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">Entscheidungsnotiz</span>
<textarea
v-model="selectedDecisionNote"
rows="5"
class="mt-2 w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Kurz festhalten, was geprüft wurde und warum du so entscheidest."
/>
</label>
<p
class="mt-2 text-xs"
:class="selectedDecisionReady ? 'text-slate-400' : 'text-rose-500'"
>
{{ selectedDecisionNoteLength }}/500 Zeichen · mind. 3 Zeichen erforderlich
</p>
<div class="mt-4 flex flex-wrap gap-3">
<Button
variant="secondary"
class="flex-1"
:disabled="riskSaving === selectedRiskFlag.id || !selectedDecisionReady"
@click="updateRiskFlagStatus(selectedRiskFlag.id, 'dismissed')"
>
<Trash2 class="mr-2 h-4 w-4" />
{{ riskSaving === selectedRiskFlag.id ? 'Speichert …' : 'Verwerfen' }}
</Button>
<Button
class="flex-1"
:disabled="riskSaving === selectedRiskFlag.id || !selectedDecisionReady"
@click="updateRiskFlagStatus(selectedRiskFlag.id, 'resolved')"
>
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ riskSaving === selectedRiskFlag.id ? 'Speichert …' : 'Erledigt markieren' }}
</Button>
</div>
</div>
</div>
</Card>
<!-- Empty state -->
<Card v-else class="flex flex-col items-center justify-center px-8 py-20 text-center">
<span class="grid h-16 w-16 place-items-center rounded-3xl border border-violet-100 bg-violet-50">
<ShieldAlert class="h-8 w-8 text-violet-300" />
</span>
<p class="mt-5 text-base font-bold text-slate-600">Kein Hinweis ausgewählt</p>
<p class="mt-2 max-w-xs text-sm leading-6 text-slate-400">
Wähle links einen Risikohinweis aus, um Details und Entscheidungsoptionen zu sehen.
</p>
</Card>
</div>
</section>
<!-- Modal: Regeln -->
<Modal <Modal
:open="activeRiskModal !== null" :open="activeModal === 'rules'"
:title="currentRiskModal?.title" title="Regel-Editor"
:subtitle="currentRiskModal?.description" subtitle="Thresholds, Zeitfenster und Severity zentral anpassen."
size="xl" size="xl"
@close="activeRiskModal = null" @close="activeModal = null"
> >
<AdminRiskRulesEditor <AdminRiskRulesEditor
v-if="activeRiskModal === 'rules'"
:rules="riskRules" :rules="riskRules"
:loading="riskRulesLoading" :loading="riskRulesLoading"
:saving="riskRulesSaving" :saving="riskRulesSaving"
@update-rule="updateRiskRule" @update-rule="updateRiskRule"
@save="saveRiskRules" @save="saveRiskRules"
/> />
</Modal>
<!-- Modal: Protokoll -->
<Modal
:open="activeModal === 'history'"
title="Entscheidungsprotokoll"
subtitle="Entschiedene Hinweise prüfen und bei Bedarf wieder öffnen."
size="xl"
@close="activeModal = null"
>
<AdminRiskHistorySection <AdminRiskHistorySection
v-else-if="activeRiskModal === 'history'"
v-model:history-status-filter="historyStatusFilter" v-model:history-status-filter="historyStatusFilter"
:risk-history="recentRiskHistory" :risk-history="recentRiskHistory"
:total-history="riskHistory.length" :total-history="riskHistory.length"