generated from corrad-software/corrad-af-2024
552 lines
25 KiB
Vue
552 lines
25 KiB
Vue
<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> |