feat(09-02): create AdminOverviewView.vue
- Single-fetch view calling getAdminOverview() on mount with loading/error states - Four stat cards grid (Users, Storage, Processing, Ready) per ADMIN-11 and D-01 - Recent audit table with 5 columns (When/Event/Actor/Target/IP) matching AuditLogTab.vue structure - Uses formatSize/formatDate from shared utils/formatters.js (no duplication) - No top-level padding (AdminLayout owns p-8 max-w-5xl mx-auto) - No Pinia store — single-fetch component consistent with admin tab pattern
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Overview</h2>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="text-gray-500">Loading overview…</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="text-red-600">{{ error }}</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<template v-else-if="overview">
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<!-- Users -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Users</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.user_count }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Storage</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ formatSize(overview.total_storage_bytes) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Processing</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.doc_status?.processing ?? 0 }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ready -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Ready</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">{{ overview.doc_status?.ready ?? 0 }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent audit table -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-3">Recent Activity</h3>
|
||||||
|
|
||||||
|
<div v-if="overview.recent_audit && overview.recent_audit.length === 0" class="text-sm text-gray-400 italic">
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else 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">
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">When</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Event</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Actor</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Target</th>
|
||||||
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="entry in overview.recent_audit"
|
||||||
|
: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">{{ formatDate(entry.created_at) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.event_type }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.owner_handle ?? '—' }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ entry.target_user_handle ?? '—' }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-gray-700">{{ entry.ip_address ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getAdminOverview } from '../../api/admin.js'
|
||||||
|
import { formatSize, formatDate } from '../../utils/formatters.js'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const overview = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
overview.value = await getAdminOverview()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || 'Failed to load overview'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user