634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
import { useAwardsStore } from '../../stores/awards'
|
|
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
|
|
|
|
const auditPageLimit = 80
|
|
const allFilter = 'all'
|
|
|
|
const actionLabels: Record<string, string> = {
|
|
'candidate.create': 'Kandidat angelegt',
|
|
'candidate.delete': 'Kandidat gelöscht',
|
|
'candidate.update': 'Kandidat aktualisiert',
|
|
'category.create': 'Kategorie angelegt',
|
|
'category.delete': 'Kategorie gelöscht',
|
|
'category.update': 'Kategorie aktualisiert',
|
|
'clip.delete': 'Clip gelöscht',
|
|
'clip.status.update': 'Clip-Status geändert',
|
|
'nomination.approve': 'Nominierung übernommen',
|
|
'nomination.reject': 'Nominierung verworfen',
|
|
'operational-settings.update': 'Betriebseinstellungen geändert',
|
|
'result.delete': 'Winner entfernt',
|
|
'result.set': 'Winner gesetzt',
|
|
'risk.resolve': 'Risk Flag entschieden',
|
|
'season.create': 'Jahr angelegt',
|
|
'season.delete': 'Jahr gelöscht',
|
|
'season.phase.update': 'Phase geändert',
|
|
'season.public.update': 'Public-Kontext geändert',
|
|
'season.update': 'Jahr aktualisiert',
|
|
'seed.initialize': 'Seed-Daten initialisiert',
|
|
'site-settings.update': 'Seiteneinstellungen geändert',
|
|
}
|
|
|
|
const groupLabels: Record<string, string> = {
|
|
candidate: 'Kandidaten',
|
|
category: 'Kategorien',
|
|
clip: 'Clips',
|
|
nomination: 'Reviews',
|
|
result: 'Gewinner',
|
|
risk: 'Risiko',
|
|
seed: 'System',
|
|
season: 'Jahre',
|
|
site: 'Seite',
|
|
'site-settings': 'Seite',
|
|
'operational-settings': 'Betrieb',
|
|
}
|
|
|
|
const relatedRoutes: Record<string, { label: string; to: string }> = {
|
|
candidate: { label: 'Kandidaten öffnen', to: '/admin/candidates' },
|
|
category: { label: 'Kategorien öffnen', to: '/admin/categories' },
|
|
clip: { label: 'Clips öffnen', to: '/admin/clips' },
|
|
nomination: { label: 'Review-Fokus öffnen', to: '/admin/nominations?review=1' },
|
|
result: { label: 'Gewinner öffnen', to: '/admin/winners' },
|
|
'risk-flag': { label: 'Risiko öffnen', to: '/admin/risk' },
|
|
season: { label: 'Jahre öffnen', to: '/admin/years' },
|
|
'site-settings': { label: 'Landingpage öffnen', to: '/admin/content' },
|
|
'operational-settings': { label: 'Einstellungen öffnen', to: '/admin/settings' },
|
|
}
|
|
|
|
export interface AuditStat {
|
|
label: string
|
|
value: string
|
|
note: string
|
|
}
|
|
|
|
export interface AuditCountItem {
|
|
key: string
|
|
label: string
|
|
count: number
|
|
}
|
|
|
|
export interface AuditFocusCard {
|
|
label: string
|
|
value: string
|
|
note: string
|
|
}
|
|
|
|
export interface AuditMetadataItem {
|
|
key: string
|
|
value: string
|
|
}
|
|
|
|
export interface AuditChangeItem {
|
|
field: string
|
|
label: string
|
|
from: string
|
|
to: string
|
|
sensitive: boolean
|
|
}
|
|
|
|
export interface AuditRelatedLink {
|
|
label: string
|
|
to: string
|
|
}
|
|
|
|
export interface AuditFilterPreset {
|
|
key: string
|
|
label: string
|
|
description: string
|
|
filters: {
|
|
query?: string
|
|
action?: string
|
|
entityType?: string
|
|
}
|
|
}
|
|
|
|
export interface AuditEntityOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
export interface AuditLogRow extends AdminAuditEntry {
|
|
actionLabel: string
|
|
actionGroup: string
|
|
actionToneClass: string
|
|
dotClass: string
|
|
entityLabel: string
|
|
createdLabel: string
|
|
ageLabel: string
|
|
metadataItems: AuditMetadataItem[]
|
|
changeItems: AuditChangeItem[]
|
|
requestContextItems: AuditMetadataItem[]
|
|
relatedLink: AuditRelatedLink | null
|
|
rawMetadataJson: string
|
|
}
|
|
|
|
const entityOptions: AuditEntityOption[] = [
|
|
{ value: allFilter, label: 'Alle Objekte' },
|
|
{ value: 'season', label: 'Jahre' },
|
|
{ value: 'category', label: 'Kategorien' },
|
|
{ value: 'candidate', label: 'Kandidaten' },
|
|
{ value: 'nomination', label: 'Reviews' },
|
|
{ value: 'clip', label: 'Clips' },
|
|
{ value: 'risk-flag', label: 'Risiko' },
|
|
{ value: 'result', label: 'Gewinner' },
|
|
{ value: 'site-settings', label: 'Landingpage' },
|
|
{ value: 'operational-settings', label: 'Betrieb' },
|
|
{ value: 'seed', label: 'System' },
|
|
]
|
|
|
|
const filterPresets: AuditFilterPreset[] = [
|
|
{
|
|
key: 'risk-security',
|
|
label: 'Risk & Security',
|
|
description: 'Entscheidungen mit Sicherheits- oder Missbrauchskontext.',
|
|
filters: { action: 'risk.resolve' },
|
|
},
|
|
{
|
|
key: 'public-context',
|
|
label: 'Public-Kontext',
|
|
description: 'Jahreswechsel, Phasen und sichtbare Public-Änderungen.',
|
|
filters: { entityType: 'season' },
|
|
},
|
|
{
|
|
key: 'content-privacy',
|
|
label: 'Content & Privacy',
|
|
description: 'Landingpage, Datenschutz und Content-Konfiguration.',
|
|
filters: { action: 'site-settings.update' },
|
|
},
|
|
{
|
|
key: 'destructive',
|
|
label: 'Löschungen',
|
|
description: 'Entfernende Aktionen aus Kategorien, Kandidaten, Clips und Jahren.',
|
|
filters: { query: 'delete' },
|
|
},
|
|
]
|
|
|
|
function countBy<T>(items: T[], getKey: (item: T) => string) {
|
|
const counts = new Map<string, number>()
|
|
for (const item of items) {
|
|
const key = getKey(item).trim() || 'unbekannt'
|
|
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.map(([key, count]) => ({ key, label: key, count }))
|
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
|
|
}
|
|
|
|
function humanizeAction(action: string) {
|
|
if (actionLabels[action]) return actionLabels[action]
|
|
return action
|
|
.split('.')
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replaceAll('_', ' '))
|
|
.join(' ')
|
|
}
|
|
|
|
function getActionGroup(action: string) {
|
|
const group = action.split('.')[0] ?? ''
|
|
return groupLabels[group] ?? 'System'
|
|
}
|
|
|
|
function getActionToneClass(action: string) {
|
|
if (action.startsWith('risk')) return 'border-rose-100 bg-rose-50 text-rose-700'
|
|
if (action.startsWith('clip') || action.startsWith('nomination')) return 'border-amber-100 bg-amber-50 text-amber-700'
|
|
if (action.startsWith('result')) return 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
|
if (action.startsWith('season')) return 'border-sky-100 bg-sky-50 text-sky-700'
|
|
if (action.startsWith('site') || action.startsWith('operational')) return 'border-indigo-100 bg-indigo-50 text-indigo-700'
|
|
if (action.includes('delete')) return 'border-slate-200 bg-slate-100 text-slate-700'
|
|
return 'border-cyan-100 bg-cyan-50 text-cyan-700'
|
|
}
|
|
|
|
function getDotClass(action: string) {
|
|
if (action.startsWith('risk')) return 'bg-rose-400'
|
|
if (action.startsWith('clip') || action.startsWith('nomination')) return 'bg-amber-400'
|
|
if (action.startsWith('result')) return 'bg-emerald-400'
|
|
if (action.startsWith('season')) return 'bg-sky-400'
|
|
if (action.startsWith('site') || action.startsWith('operational')) return 'bg-indigo-400'
|
|
if (action.includes('delete')) return 'bg-slate-400'
|
|
return 'bg-cyan-400'
|
|
}
|
|
|
|
function stringifyMetadataValue(entryValue: unknown) {
|
|
if (typeof entryValue === 'object') return JSON.stringify(entryValue)
|
|
return String(entryValue)
|
|
}
|
|
|
|
function parseMetadata(metadataJson: string | undefined) {
|
|
if (!metadataJson) return []
|
|
|
|
try {
|
|
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return []
|
|
|
|
return Object.entries(value)
|
|
.filter(([key]) => key !== 'changes' && key !== 'Changes')
|
|
.filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== '')
|
|
.map(([key, entryValue]) => ({
|
|
key,
|
|
value: stringifyMetadataValue(entryValue),
|
|
}))
|
|
} catch {
|
|
return [{ key: 'metadata', value: metadataJson }]
|
|
}
|
|
}
|
|
|
|
function parseChangeItems(metadataJson: string | undefined): AuditChangeItem[] {
|
|
if (!metadataJson) return []
|
|
|
|
try {
|
|
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
|
const changes = value.changes ?? value.Changes
|
|
if (!Array.isArray(changes)) return []
|
|
|
|
return changes
|
|
.map((change) => {
|
|
if (!change || typeof change !== 'object' || Array.isArray(change)) return null
|
|
|
|
const item = change as Record<string, unknown>
|
|
return {
|
|
field: String(item.field ?? item.Field ?? ''),
|
|
label: String(item.label ?? item.Label ?? item.field ?? item.Field ?? 'Änderung'),
|
|
from: String(item.from ?? item.From ?? ''),
|
|
to: String(item.to ?? item.To ?? ''),
|
|
sensitive: Boolean(item.sensitive ?? item.Sensitive),
|
|
}
|
|
})
|
|
.filter((change): change is AuditChangeItem => Boolean(change?.field || change?.label))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return value
|
|
return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
|
}
|
|
|
|
function formatAge(value: string) {
|
|
const timestamp = new Date(value).getTime()
|
|
if (Number.isNaN(timestamp)) return 'Zeitpunkt unbekannt'
|
|
|
|
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000))
|
|
if (minutes < 2) return 'gerade eben'
|
|
if (minutes < 60) return `vor ${minutes} Min.`
|
|
|
|
const hours = Math.round(minutes / 60)
|
|
if (hours < 48) return `vor ${hours} Std.`
|
|
|
|
return `vor ${Math.round(hours / 24)} Tagen`
|
|
}
|
|
|
|
function escapeCsvCell(value: unknown) {
|
|
const text = String(value ?? '')
|
|
if (!/[",\n\r;]/.test(text)) return text
|
|
return `"${text.replaceAll('"', '""')}"`
|
|
}
|
|
|
|
function getDateBoundary(value: string, isEndOfDay: boolean) {
|
|
if (!value) return undefined
|
|
const suffix = isEndOfDay ? 'T23:59:59.999' : 'T00:00:00.000'
|
|
const date = new Date(`${value}${suffix}`)
|
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
}
|
|
|
|
function buildRequestContextItems(entry: AdminAuditEntry) {
|
|
return [
|
|
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
|
|
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
|
|
]
|
|
}
|
|
|
|
function buildRelatedLink(entry: AdminAuditEntry) {
|
|
const route = relatedRoutes[entry.entityType]
|
|
if (!route) return null
|
|
|
|
if (entry.entityType === 'nomination' && entry.entityId) {
|
|
return { label: route.label, to: `${route.to}?nominationId=${encodeURIComponent(entry.entityId)}` }
|
|
}
|
|
|
|
return route
|
|
}
|
|
|
|
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
|
|
return {
|
|
...entry,
|
|
actionLabel: humanizeAction(entry.actionType),
|
|
actionGroup: getActionGroup(entry.actionType),
|
|
actionToneClass: getActionToneClass(entry.actionType),
|
|
dotClass: getDotClass(entry.actionType),
|
|
entityLabel: `${entry.entityType} ${entry.entityId}`.trim(),
|
|
createdLabel: formatDate(entry.createdAt),
|
|
ageLabel: formatAge(entry.createdAt),
|
|
metadataItems: parseMetadata(entry.metadataJson),
|
|
changeItems: parseChangeItems(entry.metadataJson),
|
|
requestContextItems: buildRequestContextItems(entry),
|
|
relatedLink: buildRelatedLink(entry),
|
|
rawMetadataJson: entry.metadataJson || '{}',
|
|
}
|
|
}
|
|
|
|
function downloadCsv(entries: AdminAuditEntry[]) {
|
|
const rows = [
|
|
[
|
|
'Id',
|
|
'Admin',
|
|
'Aktion',
|
|
'Objekt-Typ',
|
|
'Objekt-Id',
|
|
'Zusammenfassung',
|
|
'Zeitpunkt',
|
|
'IP',
|
|
'User-Agent',
|
|
'Metadaten',
|
|
],
|
|
...entries.map((entry) => [
|
|
entry.id,
|
|
entry.adminTwitchUserId,
|
|
entry.actionType,
|
|
entry.entityType,
|
|
entry.entityId,
|
|
entry.summary,
|
|
new Date(entry.createdAt).toISOString(),
|
|
entry.createdFromIp,
|
|
entry.userAgent,
|
|
entry.metadataJson,
|
|
]),
|
|
]
|
|
|
|
const csv = rows.map((row) => row.map(escapeCsvCell).join(';')).join('\n')
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `vtuber-star-awards-audit-${new Date().toISOString().slice(0, 10)}.csv`
|
|
document.body.append(link)
|
|
link.click()
|
|
link.remove()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
export function useAdminAuditManager() {
|
|
const store = useAwardsStore()
|
|
const query = ref('')
|
|
const selectedAdmin = ref(allFilter)
|
|
const selectedAction = ref(allFilter)
|
|
const entityFilter = ref(allFilter)
|
|
const fromDate = ref('')
|
|
const toDate = ref('')
|
|
const loadingAudit = ref(false)
|
|
const loadingMore = ref(false)
|
|
const auditError = ref('')
|
|
const exportMessage = ref('')
|
|
const lastLoadedAt = ref<Date | null>(null)
|
|
const auditEntries = ref<AdminAuditEntry[]>([])
|
|
const totalCount = ref(0)
|
|
const nextCursor = ref<string | null>(null)
|
|
const selectedEntry = ref<AuditLogRow | null>(null)
|
|
const appliedPresetKey = ref('')
|
|
let searchTimer: ReturnType<typeof window.setTimeout> | null = null
|
|
let exportMessageTimer: ReturnType<typeof window.setTimeout> | null = null
|
|
let suppressFilterWatch = false
|
|
|
|
const adminCounts = computed(() => countBy(auditEntries.value, (entry) => entry.adminTwitchUserId))
|
|
const actionCounts = computed(() =>
|
|
countBy(auditEntries.value, (entry) => entry.actionType).map((item) => ({
|
|
...item,
|
|
label: humanizeAction(item.key),
|
|
})),
|
|
)
|
|
const entityCounts = computed(() =>
|
|
countBy(auditEntries.value, (entry) => entry.entityType).map((item) => ({
|
|
...item,
|
|
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
|
|
})),
|
|
)
|
|
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map(createAuditRow))
|
|
const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length)
|
|
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length)
|
|
const recentDayCount = computed(() => {
|
|
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
|
|
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
|
|
})
|
|
const hasMore = computed(() => Boolean(nextCursor.value))
|
|
const loadedCountLabel = computed(() => auditEntries.value.length.toLocaleString('de-DE'))
|
|
const totalCountLabel = computed(() => totalCount.value.toLocaleString('de-DE'))
|
|
const logStats = computed<AuditStat[]>(() => [
|
|
{ label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
|
|
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
|
|
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
|
|
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' },
|
|
])
|
|
const focusCards = computed<AuditFocusCard[]>(() => {
|
|
const topAdmin = adminCounts.value[0]
|
|
const topAction = actionCounts.value[0]
|
|
const topEntity = entityCounts.value[0]
|
|
|
|
return [
|
|
{
|
|
label: 'Top Admin',
|
|
value: topAdmin?.label ?? 'Keine Daten',
|
|
note: topAdmin ? `${topAdmin.count} Aktionen in den geladenen Treffern` : 'Noch kein Admin aktiv',
|
|
},
|
|
{
|
|
label: 'Top Aktion',
|
|
value: topAction?.label ?? 'Keine Daten',
|
|
note: topAction ? `${topAction.count} Einträge` : 'Noch kein Aktionstyp vorhanden',
|
|
},
|
|
{
|
|
label: 'Objekt-Fokus',
|
|
value: topEntity?.label ?? 'Keine Daten',
|
|
note: topEntity ? `${topEntity.count} Einträge` : 'Noch kein Objekttyp vorhanden',
|
|
},
|
|
{
|
|
label: 'Details',
|
|
value: metadataCount.value.toLocaleString('de-DE'),
|
|
note: 'Einträge mit sichtbaren Metadaten',
|
|
},
|
|
]
|
|
})
|
|
const activeFilterCount = computed(() =>
|
|
(query.value.trim() ? 1 : 0) +
|
|
(selectedAdmin.value !== allFilter ? 1 : 0) +
|
|
(selectedAction.value !== allFilter ? 1 : 0) +
|
|
(entityFilter.value !== allFilter ? 1 : 0) +
|
|
(fromDate.value ? 1 : 0) +
|
|
(toDate.value ? 1 : 0),
|
|
)
|
|
const lastLoadedLabel = computed(() =>
|
|
lastLoadedAt.value ? lastLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
|
|
)
|
|
const emptyStateText = computed(() =>
|
|
activeFilterCount.value > 0
|
|
? 'Keine Log-Einträge passen zu den aktiven Filtern.'
|
|
: 'Noch keine Audit-Einträge vorhanden.',
|
|
)
|
|
const pageSummaryLabel = computed(() =>
|
|
`${loadedCountLabel.value} von ${totalCountLabel.value} Treffern geladen`,
|
|
)
|
|
|
|
function buildAuditOptions(cursor: string | null = null): AdminAuditQueryOptions {
|
|
return {
|
|
limit: auditPageLimit,
|
|
query: query.value,
|
|
admin: selectedAdmin.value === allFilter ? undefined : selectedAdmin.value,
|
|
action: selectedAction.value === allFilter ? undefined : selectedAction.value,
|
|
entityType: entityFilter.value === allFilter ? undefined : entityFilter.value,
|
|
from: getDateBoundary(fromDate.value, false),
|
|
to: getDateBoundary(toDate.value, true),
|
|
cursor,
|
|
}
|
|
}
|
|
|
|
function clearTimers() {
|
|
if (searchTimer) {
|
|
window.clearTimeout(searchTimer)
|
|
searchTimer = null
|
|
}
|
|
}
|
|
|
|
function scheduleLoad() {
|
|
clearTimers()
|
|
searchTimer = window.setTimeout(() => {
|
|
void loadAuditEntries()
|
|
}, 350)
|
|
}
|
|
|
|
async function loadAuditEntries(options: { append?: boolean } = {}) {
|
|
const append = options.append ?? false
|
|
if (append && !nextCursor.value) return
|
|
|
|
if (append) {
|
|
loadingMore.value = true
|
|
} else {
|
|
loadingAudit.value = true
|
|
selectedEntry.value = null
|
|
}
|
|
|
|
auditError.value = ''
|
|
|
|
try {
|
|
const response = await store.loadAdminAuditEntriesPage(
|
|
buildAuditOptions(append ? nextCursor.value : null),
|
|
append,
|
|
)
|
|
auditEntries.value = append ? [...auditEntries.value, ...response.items] : response.items
|
|
totalCount.value = response.totalCount
|
|
nextCursor.value = response.nextCursor
|
|
lastLoadedAt.value = new Date()
|
|
} catch (error) {
|
|
auditError.value = error instanceof Error ? error.message : 'Audit-Logs konnten nicht geladen werden.'
|
|
} finally {
|
|
loadingAudit.value = false
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
async function loadNextPage() {
|
|
await loadAuditEntries({ append: true })
|
|
}
|
|
|
|
function exportAuditCsv() {
|
|
if (auditEntries.value.length === 0) return
|
|
downloadCsv(auditEntries.value)
|
|
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
|
|
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
|
exportMessageTimer = window.setTimeout(() => {
|
|
exportMessage.value = ''
|
|
}, 3500)
|
|
}
|
|
|
|
async function clearFilters() {
|
|
clearTimers()
|
|
suppressFilterWatch = true
|
|
query.value = ''
|
|
selectedAdmin.value = allFilter
|
|
selectedAction.value = allFilter
|
|
entityFilter.value = allFilter
|
|
fromDate.value = ''
|
|
toDate.value = ''
|
|
appliedPresetKey.value = ''
|
|
suppressFilterWatch = false
|
|
await loadAuditEntries()
|
|
}
|
|
|
|
async function applyPreset(presetKey: string) {
|
|
const preset = filterPresets.find((item) => item.key === presetKey)
|
|
if (!preset) return
|
|
|
|
clearTimers()
|
|
suppressFilterWatch = true
|
|
query.value = preset.filters.query ?? ''
|
|
selectedAdmin.value = allFilter
|
|
selectedAction.value = preset.filters.action ?? allFilter
|
|
entityFilter.value = preset.filters.entityType ?? allFilter
|
|
fromDate.value = ''
|
|
toDate.value = ''
|
|
appliedPresetKey.value = preset.key
|
|
suppressFilterWatch = false
|
|
await loadAuditEntries()
|
|
}
|
|
|
|
function openAuditEntry(entry: AuditLogRow) {
|
|
selectedEntry.value = entry
|
|
}
|
|
|
|
function closeAuditEntry() {
|
|
selectedEntry.value = null
|
|
}
|
|
|
|
watch([query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate], () => {
|
|
if (suppressFilterWatch) return
|
|
appliedPresetKey.value = ''
|
|
scheduleLoad()
|
|
})
|
|
|
|
onMounted(() => {
|
|
auditEntries.value = store.admin.auditEntries
|
|
totalCount.value = store.admin.auditEntries.length
|
|
void loadAuditEntries()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimers()
|
|
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
|
})
|
|
|
|
return {
|
|
query,
|
|
selectedAdmin,
|
|
selectedAction,
|
|
entityFilter,
|
|
fromDate,
|
|
toDate,
|
|
loadingAudit,
|
|
loadingMore,
|
|
auditError,
|
|
exportMessage,
|
|
selectedEntry,
|
|
auditRows,
|
|
adminCounts,
|
|
actionCounts,
|
|
entityCounts,
|
|
logStats,
|
|
focusCards,
|
|
activeFilterCount,
|
|
lastLoadedLabel,
|
|
emptyStateText,
|
|
pageSummaryLabel,
|
|
hasMore,
|
|
filterPresets,
|
|
appliedPresetKey,
|
|
entityOptions,
|
|
loadAuditEntries,
|
|
loadNextPage,
|
|
exportAuditCsv,
|
|
clearFilters,
|
|
applyPreset,
|
|
openAuditEntry,
|
|
closeAuditEntry,
|
|
}
|
|
}
|