feat(10-08): skeleton tables + BreadcrumbBar + EmptyState in AdminAuditView and AdminUsersView
- AdminAuditView: add BreadcrumbBar (Audit Log, showRoot=false), replace loading spinner with 8-row skeleton tbody, replace empty div with EmptyState (icon=clipboardList) + Clear filters CTA - AdminUsersView: add BreadcrumbBar (Users, showRoot=false), replace loading spinner with 5-row x 6-col skeleton tbody - Promote CTA test to use real EmptyState (no stub) so slot content renders - All 8 skeleton tests pass GREEN
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<BreadcrumbBar :segments="[{ label: 'Audit Log' }]" :show-root="false" class="mb-4" />
|
||||
|
||||
<div class="flex flex-wrap gap-3 mb-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
|
||||
@@ -75,20 +77,9 @@
|
||||
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
||||
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
||||
Loading audit log…
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
||||
|
||||
<p v-else-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
|
||||
No audit log entries match the selected filters.
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div v-if="!fetchError" class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
@@ -100,33 +91,53 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
<span v-if="entry.user_email">{{ entry.user_email }}</span>
|
||||
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
|
||||
<span v-else>—</span>
|
||||
<template v-if="loading">
|
||||
<tr v-for="n in 8" :key="`sk-${n}`" class="border-b border-gray-100">
|
||||
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-32"></div></td>
|
||||
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
|
||||
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
|
||||
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
|
||||
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="entries.length === 0">
|
||||
<td colspan="5" class="px-0 py-0">
|
||||
<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range.">
|
||||
<template #cta>
|
||||
<button @click="clearFilters" class="mt-3 text-sm text-indigo-600 hover:underline">Clear filters</button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||
:class="actionTypeClass(entry.event_type)"
|
||||
>
|
||||
{{ entry.event_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
<span v-if="entry.user_email">{{ entry.user_email }}</span>
|
||||
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||
:class="actionTypeClass(entry.event_type)"
|
||||
>
|
||||
{{ entry.event_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4">
|
||||
<div v-if="!loading && !fetchError && entries.length > 0" class="flex items-center justify-between mt-4">
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="page <= 1"
|
||||
@@ -187,6 +198,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import * as api from '../../api/client.js'
|
||||
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
|
||||
import EmptyState from '../../components/ui/EmptyState.vue'
|
||||
|
||||
const entries = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
Reference in New Issue
Block a user