Files
BetiX/resources/js/pages/Admin/ProfileReports.vue
Dolo 0280278978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Initialer Laravel Commit für BetiX
2026-04-04 18:01:50 +02:00

360 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
import {
Search, Flag, Clock, CheckCircle2, XCircle, ChevronRight, UserRound, Eye
} from 'lucide-vue-next';
interface UserRef {
id: number;
username: string;
email: string;
avatar: string | null;
avatar_url: string | null;
role?: string;
vip_level?: number;
}
interface Report {
id: number;
reporter: UserRef;
profile: UserRef;
reason: string;
details: string | null;
status: 'pending' | 'reviewed' | 'dismissed';
admin_note: string | null;
created_at: string;
}
const props = defineProps<{
reports: { data: Report[]; links: any[]; meta: any };
filters: { status?: string; search?: string };
stats: { total: number; pending: number; reviewed: number; dismissed: number };
}>();
const filterStatus = ref(props.filters.status ?? 'pending');
const searchInput = ref(props.filters.search ?? '');
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function applyFilter() {
router.get('/admin/reports/profiles', {
status: filterStatus.value || undefined,
search: searchInput.value || undefined,
}, { preserveScroll: true });
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilter, 400);
}
function setStatus(s: string) {
filterStatus.value = s;
applyFilter();
}
function openReport(id: number) {
router.visit(`/admin/reports/profiles/${id}`);
}
function avatarSrc(u: UserRef | null) {
if (!u) return null;
return u.avatar_url || u.avatar || null;
}
function initials(name?: string) { return (name || '?')[0].toUpperCase(); }
function fmt(d: string) {
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
}
const reasonLabels: Record<string, string> = {
spam: 'Spam', harassment: 'Belästigung', inappropriate: 'Unangemessen',
fake: 'Fake', other: 'Sonstiges',
};
function rl(r: string) { return reasonLabels[r] ?? r; }
const statusMeta: Record<string, { color: string; bg: string; border: string; label: string }> = {
pending: { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', label: 'Pending' },
reviewed: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', label: 'Reviewed' },
dismissed: { color: '#6b7280', bg: 'rgba(107,114,128,0.1)', border: 'rgba(107,114,128,0.25)', label: 'Dismissed' },
};
const showingLabel = computed(() => {
if (!filterStatus.value || filterStatus.value === 'all') return 'Alle';
return statusMeta[filterStatus.value]?.label ?? filterStatus.value;
});
</script>
<template>
<CasinoAdminLayout>
<Head title="Admin Profil Reports" />
<template #title>Profil Reports</template>
<!-- Stats row -->
<div class="stats-row">
<button class="stat-card" :class="{ active: !filterStatus || filterStatus === 'all' }" @click="setStatus('all')">
<UserRound :size="16" class="sc-icon all" />
<div class="sc-body">
<span class="sc-val">{{ stats.total }}</span>
<span class="sc-label">Gesamt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'pending' }" @click="setStatus('pending')">
<Clock :size="16" class="sc-icon pending" />
<div class="sc-body">
<span class="sc-val pending">{{ stats.pending }}</span>
<span class="sc-label">Offen</span>
</div>
<span v-if="stats.pending > 0" class="sc-dot"></span>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'reviewed' }" @click="setStatus('reviewed')">
<CheckCircle2 :size="16" class="sc-icon reviewed" />
<div class="sc-body">
<span class="sc-val reviewed">{{ stats.reviewed }}</span>
<span class="sc-label">Erledigt</span>
</div>
</button>
<button class="stat-card" :class="{ active: filterStatus === 'dismissed' }" @click="setStatus('dismissed')">
<XCircle :size="16" class="sc-icon dismissed" />
<div class="sc-body">
<span class="sc-val dismissed">{{ stats.dismissed }}</span>
<span class="sc-label">Abgelehnt</span>
</div>
</button>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div class="search-wrap">
<Search :size="14" class="search-icon" />
<input
v-model="searchInput"
@input="onSearchInput"
type="text"
placeholder="Case-Nr. oder Username suchen…"
class="search-input"
/>
</div>
<div class="showing-pill">
<Eye :size="12" />
{{ showingLabel }}
<span class="showing-count">{{ reports.meta?.total ?? reports.data.length }}</span>
</div>
</div>
<!-- List -->
<div class="reports-list">
<div v-if="!reports.data.length" class="empty-state">
<Flag :size="28" class="empty-icon-svg" />
<div class="empty-text">Keine Reports gefunden</div>
<div class="empty-sub" v-if="searchInput">Für „{{ searchInput }}" wurden keine Ergebnisse gefunden.</div>
</div>
<div
v-for="r in reports.data"
:key="r.id"
class="report-card"
:class="`status-${r.status}`"
@click="openReport(r.id)"
>
<div class="card-inner">
<!-- Case badge -->
<div class="case-badge">#{{ r.id }}</div>
<!-- Reporter -->
<div class="user-block">
<div class="av-wrap reporter-av">
<img v-if="avatarSrc(r.reporter)" :src="avatarSrc(r.reporter)!" class="av-img" />
<span v-else class="av-fb">{{ initials(r.reporter?.username) }}</span>
</div>
<div class="user-meta">
<span class="meta-label">Melder</span>
<span class="meta-name">@{{ r.reporter?.username }}</span>
</div>
</div>
<div class="sep-arrow"></div>
<!-- Reported -->
<div class="user-block">
<div class="av-wrap reported-av">
<img v-if="avatarSrc(r.profile)" :src="avatarSrc(r.profile)!" class="av-img" />
<span v-else class="av-fb">{{ initials(r.profile?.username) }}</span>
</div>
<div class="user-meta">
<span class="meta-label">Gemeldet</span>
<span class="meta-name">@{{ r.profile?.username }}</span>
<span class="role-tag" v-if="r.profile?.role">{{ r.profile.role }}</span>
</div>
</div>
<!-- Reason -->
<span class="reason-chip">{{ rl(r.reason) }}</span>
<!-- Details snippet -->
<span v-if="r.details" class="detail-snip">{{ r.details }}</span>
<!-- Right meta -->
<div class="right-meta">
<span class="status-chip"
:style="{ color: statusMeta[r.status].color, background: statusMeta[r.status].bg, borderColor: statusMeta[r.status].border }"
>{{ statusMeta[r.status].label }}</span>
<span class="ts">{{ fmt(r.created_at) }}</span>
<ChevronRight :size="14" class="chevron" />
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="reports.links && reports.links.length > 3">
<template v-for="link in reports.links" :key="link.label">
<button
class="page-btn"
:class="{ active: link.active, disabled: !link.url }"
:disabled="!link.url"
@click="link.url && router.visit(link.url)"
v-html="link.label"
></button>
</template>
</div>
</CasinoAdminLayout>
</template>
<style scoped>
/* ── Stats ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
display: flex; align-items: center; gap: 12px;
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
padding: 14px 16px; cursor: pointer; transition: .15s; position: relative;
text-align: left;
}
.stat-card:hover { border-color: #2a2a2f; background: #141416; }
.stat-card.active { border-color: #333; background: #161618; }
.sc-icon { flex-shrink: 0; }
.sc-icon.all { color: #888; }
.sc-icon.pending { color: #f59e0b; }
.sc-icon.reviewed { color: #22c55e; }
.sc-icon.dismissed { color: #6b7280; }
.sc-body { display: flex; flex-direction: column; gap: 1px; }
.sc-val { font-size: 22px; font-weight: 900; color: #fff; line-height: 1; }
.sc-val.pending { color: #f59e0b; }
.sc-val.reviewed { color: #22c55e; }
.sc-val.dismissed { color: #6b7280; }
.sc-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; }
.sc-dot {
position: absolute; top: 10px; right: 10px;
width: 7px; height: 7px; border-radius: 50%; background: #f59e0b;
box-shadow: 0 0 6px rgba(245,158,11,.6);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .5; transform: scale(.7); }
}
/* ── Toolbar ── */
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.search-wrap { flex: 1; position: relative; max-width: 380px; }
.search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #444; pointer-events: none; }
.search-input {
width: 100%; background: #111113; border: 1px solid #1e1e21; border-radius: 9px;
color: #ccc; font-size: 13px; padding: 9px 12px 9px 34px; outline: none; transition: .15s;
}
.search-input::placeholder { color: #3a3a3f; }
.search-input:focus { border-color: #333; }
.showing-pill {
display: flex; align-items: center; gap: 6px;
background: #111113; border: 1px solid #1e1e21; border-radius: 20px;
padding: 6px 12px; font-size: 12px; color: #555; margin-left: auto;
}
.showing-count {
background: #1a1a1c; border: 1px solid #252528; border-radius: 10px;
padding: 1px 7px; color: #888; font-size: 11px; font-weight: 700;
}
/* ── List ── */
.reports-list { display: flex; flex-direction: column; gap: 6px; }
.empty-state { text-align: center; padding: 60px 20px; background: #111113; border: 1px solid #1e1e21; border-radius: 14px; }
.empty-icon-svg { color: #2a2a2f; margin: 0 auto 12px; }
.empty-text { color: #555; font-size: 14px; font-weight: 700; }
.empty-sub { color: #3a3a3f; font-size: 12px; margin-top: 4px; }
.report-card {
background: #111113; border: 1px solid #1e1e21; border-radius: 12px;
overflow: hidden; cursor: pointer; transition: .15s;
}
.report-card:hover { background: #141416; border-color: #272729; }
.report-card.status-pending { border-left: 3px solid #f59e0b; }
.report-card.status-reviewed { border-left: 3px solid #22c55e; opacity: .65; }
.report-card.status-dismissed { border-left: 3px solid #252528; opacity: .45; }
.card-inner {
display: flex; align-items: center; gap: 14px;
padding: 12px 16px; flex-wrap: wrap;
}
.case-badge {
font-size: 11px; font-weight: 800; color: #666;
background: #161618; border: 1px solid #252528;
padding: 3px 9px; border-radius: 6px; white-space: nowrap; flex-shrink: 0;
}
.user-block { display: flex; align-items: center; gap: 9px; }
.av-wrap { width: 34px; height: 34px; border-radius: 50%; overflow: hidden; flex-shrink: 0; }
.av-img { width: 100%; height: 100%; object-fit: cover; }
.av-fb {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 800; border: 1px solid;
}
.reporter-av .av-fb { background: rgba(59,130,246,.12); color: #3b82f6; border-color: rgba(59,130,246,.25); }
.reported-av .av-fb { background: rgba(223,0,106,.12); color: #df006a; border-color: rgba(223,0,106,.25); }
.user-meta { display: flex; flex-direction: column; gap: 1px; }
.meta-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .4px; }
.meta-name { font-size: 12px; font-weight: 700; color: #ddd; white-space: nowrap; }
.role-tag { font-size: 9px; color: #666; background: #161618; padding: 1px 5px; border-radius: 4px; border: 1px solid #252528; width: fit-content; }
.sep-arrow { color: #333; font-size: 16px; flex-shrink: 0; }
.reason-chip {
font-size: 10px; font-weight: 700; color: #f59e0b;
background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.25);
padding: 3px 10px; border-radius: 20px; white-space: nowrap; flex-shrink: 0;
}
.detail-snip {
font-size: 11px; color: #555; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.right-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.status-chip {
font-size: 10px; font-weight: 800; padding: 3px 9px;
border-radius: 20px; border: 1px solid;
text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
}
.ts { font-size: 11px; color: #444; white-space: nowrap; }
.chevron { color: #333; flex-shrink: 0; }
/* ── Pagination ── */
.pagination { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 18px; justify-content: center; }
.page-btn {
background: #111; border: 1px solid #222; color: #ccc;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; transition: .2s;
}
.page-btn.active { background: #df006a; border-color: #df006a; color: #fff; }
.page-btn.disabled { opacity: .4; cursor: default; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: 1fr 1fr; }
}
</style>