Refine admin risk workspace
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user