EDMS/pages/dms/admin-dashboard.vue
Aiman Fakhrullah Mantasan b8373092f3 Removed Unecessary Pages
2025-06-06 12:50:24 +08:00

552 lines
25 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useDmsStore } from '~/stores/dms';
// Define page metadata
definePageMeta({
title: "Admin Dashboard",
middleware: ["auth"],
requiresAuth: true,
breadcrumb: [
{ name: "DMS", path: "/dms" },
{ name: "Admin Dashboard", path: "/dms/admin-dashboard" }
],
});
// Store
const dmsStore = useDmsStore();
// Component state
const isLoading = ref(true);
const timeRange = ref('30days');
const accessRequests = ref([]);
const deptRequests = ref([]);
// Time range options
const timeRangeOptions = [
{ id: '7days', label: 'Last 7 Days' },
{ id: '30days', label: 'Last 30 Days' },
{ id: '90days', label: 'Last 90 Days' },
{ id: '365days', label: 'Last Year' }
];
// Check if user is admin
const isAdmin = computed(() => {
return dmsStore.currentUser.role === 'admin';
});
// Calculate KPIs
const kpis = computed(() => {
if (!accessRequests.value.length) return null;
const total = accessRequests.value.length;
const pending = accessRequests.value.filter(req => req.status === 'pending').length;
const approved = accessRequests.value.filter(req => req.status === 'approved').length;
const rejected = accessRequests.value.filter(req => req.status === 'rejected').length;
// Calculate response times
const resolvedRequests = accessRequests.value.filter(
req => req.status === 'approved' || req.status === 'rejected'
);
let totalResponseTime = 0;
let fastestResponse = Infinity;
let slowestResponse = 0;
resolvedRequests.forEach(req => {
const requestDate = new Date(req.requestDate);
const responseDate = new Date(req.responseDate);
const responseTimeHours = (responseDate - requestDate) / (1000 * 60 * 60);
totalResponseTime += responseTimeHours;
fastestResponse = Math.min(fastestResponse, responseTimeHours);
slowestResponse = Math.max(slowestResponse, responseTimeHours);
});
const avgResponseTime = resolvedRequests.length > 0
? totalResponseTime / resolvedRequests.length
: 0;
// Calculate overdue
const overdueRequests = accessRequests.value.filter(req => {
if (req.status !== 'pending') return false;
const deadline = new Date(req.targetResolutionTime);
const now = new Date();
return now > deadline;
}).length;
const overduePercentage = total > 0
? (overdueRequests / total) * 100
: 0;
// Calculate SLA compliance
const slaCompliance = resolvedRequests.length > 0
? (resolvedRequests.filter(req => {
const requestDate = new Date(req.requestDate);
const responseDate = new Date(req.responseDate);
const targetDate = new Date(req.targetResolutionTime);
return responseDate <= targetDate;
}).length / resolvedRequests.length) * 100
: 0;
// Approval rate
const approvalRate = resolvedRequests.length > 0
? (approved / resolvedRequests.length) * 100
: 0;
return {
total,
pending,
approved,
rejected,
avgResponseTime,
fastestResponse: fastestResponse === Infinity ? 0 : fastestResponse,
slowestResponse,
overdueRequests,
overduePercentage,
slaCompliance,
approvalRate
};
});
// Department performance metrics
const deptPerformance = computed(() => {
if (!deptRequests.value.length) return [];
// Group by department
const departments = {};
deptRequests.value.forEach(req => {
const dept = req.approverDepartment || 'Unassigned';
if (!departments[dept]) {
departments[dept] = {
name: dept,
total: 0,
approved: 0,
rejected: 0,
pending: 0,
avgResponseHours: 0,
responseCount: 0,
totalResponseHours: 0,
overdueCount: 0
};
}
const deptData = departments[dept];
deptData.total++;
if (req.status === 'approved') deptData.approved++;
if (req.status === 'rejected') deptData.rejected++;
if (req.status === 'pending') deptData.pending++;
// Calculate response time
if (req.responseDate) {
const requestDate = new Date(req.requestDate);
const responseDate = new Date(req.responseDate);
const responseTimeHours = (responseDate - requestDate) / (1000 * 60 * 60);
deptData.totalResponseHours += responseTimeHours;
deptData.responseCount++;
}
// Check if overdue
if (req.status === 'pending') {
const deadline = new Date(req.targetResolutionTime);
const now = new Date();
if (now > deadline) {
deptData.overdueCount++;
}
}
});
// Calculate averages and format data
return Object.values(departments).map(dept => {
dept.avgResponseHours = dept.responseCount > 0
? dept.totalResponseHours / dept.responseCount
: 0;
dept.overduePercentage = dept.total > 0
? (dept.overdueCount / dept.total) * 100
: 0;
dept.approvalRate = (dept.approved + dept.rejected) > 0
? (dept.approved / (dept.approved + dept.rejected)) * 100
: 0;
return dept;
}).sort((a, b) => b.total - a.total);
});
// Format helpers
const formatDuration = (hours) => {
if (hours < 1) {
return `${Math.round(hours * 60)} minutes`;
} else if (hours < 24) {
return `${Math.round(hours)} hours`;
} else {
return `${Math.round(hours / 24)} days`;
}
};
const formatPercentage = (value) => {
return `${Math.round(value)}%`;
};
// Load data
const loadData = async () => {
isLoading.value = true;
try {
// Get the store instance
const store = useDmsStore();
// Load access metrics based on selected time range
const metrics = await store.getAccessRequestMetrics(timeRange.value);
// Load all access requests
const [requests, departmentReqs] = await Promise.all([
store.getAccessRequests(),
store.getDepartmentAccessRequests()
]);
accessRequests.value = requests;
deptRequests.value = departmentReqs;
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
isLoading.value = false;
}
};
// Update time range
const updateTimeRange = (range) => {
timeRange.value = range;
loadData();
};
// Lifecycle
onMounted(() => {
loadData();
});
</script>
<template>
<div class="admin-dashboard">
<LayoutsBreadcrumb />
<rs-card class="h-full">
<template #body>
<div class="h-full flex flex-col overflow-hidden">
<!-- Admin check -->
<div v-if="!isAdmin" class="flex-1 flex items-center justify-center overflow-hidden">
<div class="text-center p-6 max-w-md">
<div class="text-red-500 text-5xl mb-4"></div>
<h2 class="text-xl font-semibold text-red-600 mb-2">Admin Access Required</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
You need administrator privileges to access this dashboard.
</p>
<NuxtLink to="/dms/switch-roles" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium inline-block">
Switch Role
</NuxtLink>
</div>
</div>
<!-- Admin Dashboard -->
<template v-else>
<!-- Dashboard Header -->
<div class="bg-indigo-50 dark:bg-indigo-900/20 px-6 py-4 border-b border-indigo-200 dark:border-indigo-800 flex-shrink-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-indigo-900 dark:text-indigo-100">
Access Request Performance Dashboard
</h1>
<p class="text-indigo-700 dark:text-indigo-300 text-sm">
Monitor approval KPIs, response times, and department performance
</p>
</div>
<!-- Time Range Selector -->
<div class="flex items-center self-end sm:self-center">
<div class="inline-flex rounded-md shadow-sm mr-2">
<button
v-for="option in timeRangeOptions"
:key="option.id"
@click="updateTimeRange(option.id)"
class="relative inline-flex items-center px-3 py-1.5 text-sm font-medium border transition-colors whitespace-nowrap"
:class="[
timeRange === option.id
? 'bg-indigo-100 border-indigo-300 text-indigo-800 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300 z-10'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300',
option.id === '7days' ? 'rounded-l-md' : '',
option.id === '365days' ? 'rounded-r-md' : ''
]"
>
{{ option.label }}
</button>
</div>
<button
@click="loadData"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center overflow-hidden">
<div class="text-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading dashboard data...</p>
</div>
</div>
<!-- Dashboard Content -->
<div v-else class="flex-1 overflow-auto p-4 md:p-6">
<!-- Navigation Buttons -->
<div class="flex flex-wrap justify-end mb-4 gap-2">
<NuxtLink
to="/dms"
class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Return to DMS
</NuxtLink>
<NuxtLink
to="/dms/switch-roles"
class="inline-flex items-center px-3 py-2 border border-indigo-300 dark:border-indigo-600 rounded-md text-sm font-medium text-indigo-700 dark:text-indigo-300 bg-white dark:bg-gray-800 hover:bg-indigo-50 dark:hover:bg-indigo-900/10"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Switch Role
</NuxtLink>
</div>
<!-- KPI Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Requests -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ kpis?.total || 0 }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Total Requests</div>
</div>
</div>
</div>
<!-- Pending Requests -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg bg-yellow-100 dark:bg-yellow-900/20 flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-600 dark:text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ kpis?.pending || 0 }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Pending Requests</div>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900/20 flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDuration(kpis?.avgResponseTime || 0) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Response Time</div>
</div>
</div>
</div>
<!-- SLA Compliance -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<div class="w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/20 flex items-center justify-center mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatPercentage(kpis?.slaCompliance || 0) }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">SLA Compliance</div>
</div>
</div>
</div>
</div>
<!-- Performance Metrics -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Response Time Metrics -->
<RsCard
height="fit"
overflow="hidden"
bodyPadding="md"
>
<template #header>
<h2 class="font-medium text-gray-900 dark:text-gray-100">Response Time Metrics</h2>
</template>
<template #body>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="p-3 bg-blue-50 dark:bg-blue-900/10 rounded-lg border border-blue-100 dark:border-blue-800">
<div class="text-sm text-blue-700 dark:text-blue-300 mb-1">Average</div>
<div class="text-xl font-bold text-blue-900 dark:text-blue-100 truncate" :title="formatDuration(kpis?.avgResponseTime || 0)">{{ formatDuration(kpis?.avgResponseTime || 0) }}</div>
</div>
<div class="p-3 bg-green-50 dark:bg-green-900/10 rounded-lg border border-green-100 dark:border-green-800">
<div class="text-sm text-green-700 dark:text-green-300 mb-1">Fastest</div>
<div class="text-xl font-bold text-green-900 dark:text-green-100 truncate" :title="formatDuration(kpis?.fastestResponse || 0)">{{ formatDuration(kpis?.fastestResponse || 0) }}</div>
</div>
<div class="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-800">
<div class="text-sm text-red-700 dark:text-red-300 mb-1">Slowest</div>
<div class="text-xl font-bold text-red-900 dark:text-red-100 truncate" :title="formatDuration(kpis?.slowestResponse || 0)">{{ formatDuration(kpis?.slowestResponse || 0) }}</div>
</div>
</div>
</template>
</RsCard>
<!-- Approval Metrics -->
<RsCard
height="fit"
overflow="hidden"
bodyPadding="md"
>
<template #header>
<h2 class="font-medium text-gray-900 dark:text-gray-100">Approval Metrics</h2>
</template>
<template #body>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="p-3 bg-green-50 dark:bg-green-900/10 rounded-lg border border-green-100 dark:border-green-800">
<div class="text-sm text-green-700 dark:text-green-300 mb-1">Approved</div>
<div class="text-xl font-bold text-green-900 dark:text-green-100 truncate" :title="(kpis?.approved || 0).toString()">{{ kpis?.approved || 0 }}</div>
</div>
<div class="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-800">
<div class="text-sm text-red-700 dark:text-red-300 mb-1">Rejected</div>
<div class="text-xl font-bold text-red-900 dark:text-red-100 truncate" :title="(kpis?.rejected || 0).toString()">{{ kpis?.rejected || 0 }}</div>
</div>
<div class="p-3 bg-purple-50 dark:bg-purple-900/10 rounded-lg border border-purple-100 dark:border-purple-800">
<div class="text-sm text-purple-700 dark:text-purple-300 mb-1">Approval Rate</div>
<div class="text-xl font-bold text-purple-900 dark:text-purple-100 truncate" :title="formatPercentage(kpis?.approvalRate || 0)">{{ formatPercentage(kpis?.approvalRate || 0) }}</div>
</div>
</div>
</template>
</RsCard>
</div>
<!-- Department Performance Table -->
<RsCard
height="fit"
overflow="hidden"
bodyPadding="none"
maxHeight="600px"
>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-medium text-gray-900 dark:text-gray-100">Department Performance</h2>
<RsButton variant="secondary-outline" size="sm">
<Icon name="mdi:download" class="w-4 h-4 mr-2" />
Export Report
</RsButton>
</div>
</template>
<template #body>
<div class="overflow-auto h-full">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
DEPARTMENT
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
TOTAL REQUESTS
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
AVG RESPONSE TIME
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
APPROVAL RATE
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
PENDING
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
OVERDUE %
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="dept in deptPerformance" :key="dept.name" class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ dept.name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-gray-100">{{ dept.total }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-gray-100">{{ formatDuration(dept.avgResponseHours) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-gray-100">{{ formatPercentage(dept.approvalRate) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 dark:text-gray-100">{{ dept.pending }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
dept.overduePercentage > 10 ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
dept.overduePercentage > 0 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400' :
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
]"
>
{{ formatPercentage(dept.overduePercentage) }}
</span>
</td>
</tr>
<!-- Empty state -->
<tr v-if="deptPerformance.length === 0">
<td colspan="6" class="px-6 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No department data available for the selected time period.</p>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</RsCard>
</div>
</template>
</div>
</template>
</rs-card>
</div>
</template>
<style scoped>
.admin-dashboard {
height: calc(100vh - 64px);
}
</style>