generated from corrad-software/corrad-af-2024
Added Access Management and Role Management
This commit is contained in:
parent
b157b8b47d
commit
8cff207c45
296
components/dms/dialogs/DMSAccessApprovalDialog.vue
Normal file
296
components/dms/dialogs/DMSAccessApprovalDialog.vue
Normal file
@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
request: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'submit']);
|
||||
|
||||
// Form state
|
||||
const approvalNotes = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// Computed properties
|
||||
const documentName = computed(() => {
|
||||
return props.request?.documentName || 'Document';
|
||||
});
|
||||
|
||||
const documentPath = computed(() => {
|
||||
return props.request?.documentPath || '';
|
||||
});
|
||||
|
||||
const requesterInfo = computed(() => {
|
||||
return {
|
||||
name: props.request?.requesterName || 'Unknown',
|
||||
department: props.request?.requesterDepartment || '',
|
||||
email: props.request?.requesterEmail || '',
|
||||
justification: props.request?.justification || 'No justification provided'
|
||||
};
|
||||
});
|
||||
|
||||
const requestType = computed(() => {
|
||||
return props.request?.accessType || 'view';
|
||||
});
|
||||
|
||||
const requestDate = computed(() => {
|
||||
if (!props.request?.requestDate) return '';
|
||||
const date = new Date(props.request.requestDate);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
});
|
||||
|
||||
const accessDuration = computed(() => {
|
||||
return props.request?.accessDuration || '7 days';
|
||||
});
|
||||
|
||||
// Methods
|
||||
const closeDialog = () => {
|
||||
// Reset form
|
||||
approvalNotes.value = '';
|
||||
formError.value = '';
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (approvalNotes.value.trim().length < 3) {
|
||||
formError.value = 'Please provide notes for your decision';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const approveRequest = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
emit('submit', {
|
||||
action: 'approve',
|
||||
notes: approvalNotes.value
|
||||
});
|
||||
} catch (error) {
|
||||
formError.value = 'Failed to approve request. Please try again.';
|
||||
console.error('Approval error:', error);
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectRequest = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
emit('submit', {
|
||||
action: 'reject',
|
||||
notes: approvalNotes.value
|
||||
});
|
||||
} catch (error) {
|
||||
formError.value = 'Failed to reject request. Please try again.';
|
||||
console.error('Rejection error:', error);
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Format the access type label
|
||||
const getAccessTypeLabel = (type) => {
|
||||
const types = {
|
||||
'view': 'View Only',
|
||||
'download': 'Download',
|
||||
'print': 'Print',
|
||||
'full': 'Full Access'
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
// Format the access type description
|
||||
const getAccessTypeDescription = (type) => {
|
||||
const descriptions = {
|
||||
'view': 'Can only view the document',
|
||||
'download': 'Can view and download',
|
||||
'print': 'Can view and print',
|
||||
'full': 'View, download and print'
|
||||
};
|
||||
return descriptions[type] || '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<rs-modal
|
||||
:visible="visible"
|
||||
@close="closeDialog"
|
||||
title="Review Access Request"
|
||||
size="lg"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-6">
|
||||
<!-- Form error message -->
|
||||
<div v-if="formError" class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded-md text-sm">
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout for request details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Left column - Document details -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Document Information</h3>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 rounded-lg p-4 border border-blue-200 dark:border-blue-800 mb-4">
|
||||
<div class="flex items-start mb-3">
|
||||
<span class="flex-shrink-0 h-9 w-9 rounded-md bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-100">{{ documentName }}</div>
|
||||
<div class="text-xs text-blue-800 dark:text-blue-200">{{ documentPath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="text-gray-700 dark:text-gray-300 font-medium">Access Type:</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ getAccessTypeLabel(requestType) }}</div>
|
||||
|
||||
<div class="text-gray-700 dark:text-gray-300 font-medium">Access Duration:</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ accessDuration }}</div>
|
||||
|
||||
<div class="text-gray-700 dark:text-gray-300 font-medium">Requested:</div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ requestDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Access Type Details</h3>
|
||||
<div class="bg-indigo-50 dark:bg-indigo-900/10 rounded-lg p-4 border border-indigo-200 dark:border-indigo-800">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-indigo-900 dark:text-indigo-100">
|
||||
{{ getAccessTypeLabel(requestType) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-indigo-800 dark:text-indigo-200 ml-11">
|
||||
{{ getAccessTypeDescription(requestType) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column - Requester details and justification -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Requester Information</h3>
|
||||
<div class="bg-purple-50 dark:bg-purple-900/10 rounded-lg p-4 border border-purple-200 dark:border-purple-800 mb-4">
|
||||
<div class="flex items-start mb-3">
|
||||
<span class="flex-shrink-0 h-9 w-9 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center text-purple-600 dark:text-purple-400 mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-purple-900 dark:text-purple-100">{{ requesterInfo.name }}</div>
|
||||
<div class="text-xs text-purple-800 dark:text-purple-200">
|
||||
{{ requesterInfo.department }}
|
||||
<span v-if="requesterInfo.email">• {{ requesterInfo.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Request Justification</h3>
|
||||
<div class="bg-green-50 dark:bg-green-900/10 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600 dark:text-green-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
<div class="text-sm font-medium text-green-900 dark:text-green-100">Justification</div>
|
||||
</div>
|
||||
<p class="text-sm text-green-800 dark:text-green-200 whitespace-pre-line">
|
||||
{{ requesterInfo.justification }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approval notes -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Decision Notes <span class="text-red-500">*</span></h3>
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<textarea
|
||||
v-model="approvalNotes"
|
||||
rows="3"
|
||||
placeholder="Enter your approval or rejection notes here..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Please provide a reason for your decision. This will be visible to the requester.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<rs-button
|
||||
@click="closeDialog"
|
||||
:disabled="isSubmitting"
|
||||
variant="secondary-outline"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<rs-button
|
||||
@click="rejectRequest"
|
||||
:disabled="isSubmitting"
|
||||
variant="danger-outline"
|
||||
size="sm"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Reject
|
||||
</rs-button>
|
||||
|
||||
<rs-button
|
||||
@click="approveRequest"
|
||||
:disabled="isSubmitting"
|
||||
variant="success"
|
||||
size="sm"
|
||||
>
|
||||
<svg v-if="isSubmitting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Approve
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</template>
|
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
|
||||
const props = defineProps({
|
||||
@ -19,216 +19,273 @@ const emit = defineEmits(['close', 'submit']);
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// Form state
|
||||
const selectedAccessType = ref('view');
|
||||
const accessDuration = ref('7 days');
|
||||
const accessType = ref('view');
|
||||
const justification = ref('');
|
||||
const accessDuration = ref('7 days');
|
||||
const isSubmitting = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// Access type options
|
||||
const accessTypes = [
|
||||
{ id: 'view', label: 'View Only', description: 'Can only view the document' },
|
||||
{ id: 'download', label: 'Download', description: 'Can view and download' },
|
||||
{ id: 'print', label: 'Print', description: 'Can view and print' },
|
||||
{ id: 'full', label: 'Full Access', description: 'View, download and print' }
|
||||
// Durations
|
||||
const durationOptions = [
|
||||
{ value: '1 day', label: '1 Day' },
|
||||
{ value: '7 days', label: '7 Days' },
|
||||
{ value: '14 days', label: '14 Days' },
|
||||
{ value: '30 days', label: '30 Days' },
|
||||
{ value: '90 days', label: '90 Days' }
|
||||
];
|
||||
|
||||
// Access duration options
|
||||
const durationOptions = [
|
||||
'7 days',
|
||||
'14 days',
|
||||
'30 days',
|
||||
'60 days',
|
||||
'90 days',
|
||||
'Permanent'
|
||||
// Access type options
|
||||
const accessTypeOptions = [
|
||||
{
|
||||
value: 'view',
|
||||
label: 'View Only',
|
||||
description: 'View documents without the ability to download or edit',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>`
|
||||
},
|
||||
{
|
||||
value: 'download',
|
||||
label: 'Download',
|
||||
description: 'View and download documents without editing',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`
|
||||
},
|
||||
{
|
||||
value: 'full',
|
||||
label: 'Full Access',
|
||||
description: 'View, download, and edit documents',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="3"></circle></svg>`
|
||||
}
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
const itemTitle = computed(() => {
|
||||
const itemName = computed(() => {
|
||||
return props.item?.name || 'Document';
|
||||
});
|
||||
|
||||
const itemFileName = computed(() => {
|
||||
if (props.item?.type === 'file') {
|
||||
return props.item.name;
|
||||
const itemPath = computed(() => {
|
||||
if (!props.item) return '';
|
||||
if (props.item.path) return props.item.path;
|
||||
return props.item.parentPath ? `${props.item.parentPath}/${props.item.name}` : `/${props.item.name}`;
|
||||
});
|
||||
|
||||
const itemType = computed(() => {
|
||||
if (!props.item) return 'document';
|
||||
|
||||
if (props.item.type === 'cabinet' || props.item.type === 'cabinet-group') {
|
||||
return 'cabinet';
|
||||
} else if (props.item.type === 'drawer') {
|
||||
return 'drawer';
|
||||
} else if (props.item.type === 'folder' || props.item.type === 'subfolder') {
|
||||
return 'folder';
|
||||
} else {
|
||||
return 'document';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const isValidForm = computed(() => {
|
||||
return accessType.value && justification.value.length >= 10 && accessDuration.value;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const closeDialog = () => {
|
||||
// Reset form
|
||||
selectedAccessType.value = 'view';
|
||||
accessDuration.value = '7 days';
|
||||
const resetForm = () => {
|
||||
accessType.value = 'view';
|
||||
justification.value = '';
|
||||
accessDuration.value = '7 days';
|
||||
formError.value = '';
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const submitRequest = async () => {
|
||||
// Validate form
|
||||
if (!selectedAccessType.value) {
|
||||
formError.value = 'Please select an access type';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!justification.value.trim()) {
|
||||
formError.value = 'Please provide a justification for your request';
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
// Reset form after closing
|
||||
setTimeout(() => {
|
||||
resetForm();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValidForm.value) {
|
||||
formError.value = 'Please complete all required fields';
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
// Submit the request to the store
|
||||
const request = await dmsStore.requestAccess(
|
||||
props.item.id,
|
||||
selectedAccessType.value,
|
||||
justification.value,
|
||||
accessDuration.value
|
||||
);
|
||||
|
||||
// Emit success event
|
||||
emit('submit', request);
|
||||
|
||||
// Close the dialog (this will also reset the form)
|
||||
closeDialog();
|
||||
emit('submit', {
|
||||
accessType: accessType.value,
|
||||
justification: justification.value,
|
||||
accessDuration: accessDuration.value
|
||||
});
|
||||
} catch (error) {
|
||||
formError.value = 'Failed to submit access request. Please try again.';
|
||||
console.error('Access request error:', error);
|
||||
console.error('Submit error:', error);
|
||||
formError.value = error.message || 'Failed to submit request';
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for visibility changes
|
||||
watch(() => props.visible, (newValue) => {
|
||||
if (newValue) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<rs-modal
|
||||
:visible="visible"
|
||||
@close="closeDialog"
|
||||
title="Request Document Access"
|
||||
<rs-modal
|
||||
v-model="props.visible"
|
||||
size="md"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Request Access</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="p-6">
|
||||
<!-- Document Information Section -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/10 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">Document Information</h3>
|
||||
<div class="space-y-1">
|
||||
<div class="flex">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12">Title:</span>
|
||||
<span class="text-sm text-gray-900 dark:text-gray-100">{{ itemTitle }}</span>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12">File:</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ itemFileName }}</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<!-- Item information -->
|
||||
<div class="mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h3 class="text-gray-700 dark:text-gray-300 text-sm font-medium mb-2">Requesting access to:</h3>
|
||||
<div class="flex items-start">
|
||||
<div class="mt-1 mr-3">
|
||||
<span v-if="itemType === 'cabinet'" class="text-blue-600 dark:text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else-if="itemType === 'drawer'" class="text-blue-600 dark:text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else-if="itemType === 'folder'" class="text-blue-600 dark:text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="text-gray-600 dark:text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-gray-900 dark:text-gray-100 font-medium">{{ itemName }}</h4>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs mt-1">{{ itemPath }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form error -->
|
||||
<div v-if="formError" class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 rounded-md text-sm">
|
||||
<div v-if="formError" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 text-sm">
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<!-- Access Type Section -->
|
||||
<!-- Access type -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Access Type</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Type
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="accessType in accessTypes"
|
||||
:key="accessType.id"
|
||||
class="relative"
|
||||
v-for="option in accessTypeOptions"
|
||||
:key="option.value"
|
||||
class="relative flex items-start"
|
||||
>
|
||||
<label
|
||||
:for="accessType.id"
|
||||
class="flex items-start p-3 border rounded-lg cursor-pointer transition-colors"
|
||||
:class="selectedAccessType === accessType.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'"
|
||||
>
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
:id="accessType.id"
|
||||
v-model="selectedAccessType"
|
||||
:value="accessType.id"
|
||||
type="radio"
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
:id="`access-type-${option.value}`"
|
||||
type="radio"
|
||||
v-model="accessType"
|
||||
:value="option.value"
|
||||
class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 flex items-center">
|
||||
<span v-html="option.icon" class="mr-2 text-gray-600 dark:text-gray-400"></span>
|
||||
<div>
|
||||
<label
|
||||
:for="`access-type-${option.value}`"
|
||||
class="font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{{ option.label }}
|
||||
</label>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs">{{ option.description }}</p>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ accessType.label }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ accessType.description }}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Duration Section -->
|
||||
<!-- Justification -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Access Duration</label>
|
||||
<select
|
||||
v-model="accessDuration"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option v-for="duration in durationOptions" :key="duration" :value="duration">
|
||||
{{ duration }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Justification Section -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Justification
|
||||
<label for="justification" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Justification
|
||||
<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
<textarea
|
||||
id="justification"
|
||||
v-model="justification"
|
||||
rows="4"
|
||||
placeholder="Please explain why you need access to this document..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
rows="3"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded-md"
|
||||
placeholder="Please explain why you need access to this resource..."
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Minimum 10 characters. Please provide a detailed explanation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer Note -->
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your request will be reviewed by the document owner or administrator.
|
||||
</p>
|
||||
<!-- Access Duration -->
|
||||
<div class="mb-6">
|
||||
<label for="duration" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Duration
|
||||
</label>
|
||||
<select
|
||||
id="duration"
|
||||
v-model="accessDuration"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 rounded-md"
|
||||
>
|
||||
<option v-for="option in durationOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
How long do you need access to this resource?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-800">
|
||||
<rs-button
|
||||
@click="closeDialog"
|
||||
:disabled="isSubmitting"
|
||||
variant="secondary-outline"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button
|
||||
@click="submitRequest"
|
||||
:disabled="isSubmitting"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<svg v-if="isSubmitting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Submit Request
|
||||
</rs-button>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
* Required fields
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<rs-button
|
||||
variant="light"
|
||||
@click="handleClose"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Cancel
|
||||
</rs-button>
|
||||
<rs-button
|
||||
variant="primary"
|
||||
@click="handleSubmit"
|
||||
:disabled="!isValidForm || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
Submit Request
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
@ -5,6 +5,7 @@ import DMSDocumentViewer from '~/components/dms/viewers/DMSDocumentViewer.vue';
|
||||
import DMSUploadDialog from '~/components/dms/dialogs/DMSUploadDialog.vue';
|
||||
import DMSCreateNewDialog from '~/components/dms/dialogs/DMSCreateNewDialog.vue';
|
||||
import DMSAccessRequestDialog from '~/components/dms/dialogs/DMSAccessRequestDialog.vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
|
||||
// Props for the explorer
|
||||
const props = defineProps({
|
||||
@ -36,6 +37,9 @@ const sortOrder = ref('asc'); // asc, desc
|
||||
const searchQuery = ref('');
|
||||
const showAccessRequestDialog = ref(false);
|
||||
const accessRequestItem = ref(null);
|
||||
const isRequestingAccess = ref(false);
|
||||
const requestSuccess = ref(false);
|
||||
const requestError = ref(null);
|
||||
|
||||
// Navigation history for back/forward functionality
|
||||
const navigationHistory = ref([props.initialPath]);
|
||||
@ -52,6 +56,7 @@ const currentDocument = ref(null);
|
||||
// Mock data - replace with actual API calls
|
||||
const allItems = ref([]);
|
||||
const router = useRouter();
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// Methods
|
||||
const loadItems = async () => {
|
||||
@ -600,6 +605,39 @@ const closeAccessRequestDialog = () => {
|
||||
accessRequestItem.value = null;
|
||||
};
|
||||
|
||||
const handleAccessRequest = async (item) => {
|
||||
if (!item) return;
|
||||
|
||||
accessRequestItem.value = item;
|
||||
showAccessRequestDialog.value = true;
|
||||
};
|
||||
|
||||
const submitAccessRequest = async (requestData) => {
|
||||
isRequestingAccess.value = true;
|
||||
requestError.value = null;
|
||||
|
||||
try {
|
||||
await dmsStore.requestAccess(
|
||||
accessRequestItem.value.id,
|
||||
requestData.accessType,
|
||||
requestData.justification,
|
||||
requestData.accessDuration
|
||||
);
|
||||
|
||||
requestSuccess.value = true;
|
||||
setTimeout(() => {
|
||||
showAccessRequestDialog.value = false;
|
||||
requestSuccess.value = false;
|
||||
accessRequestItem.value = null;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
requestError.value = error.message || 'Failed to submit access request. Please try again.';
|
||||
console.error('Access request error:', error);
|
||||
} finally {
|
||||
isRequestingAccess.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const currentItems = computed(() => {
|
||||
let items = allItems.value.filter(item => item.parentPath === currentPath.value);
|
||||
@ -1119,6 +1157,33 @@ const parentType = computed(() => {
|
||||
<span v-if="item.accessType === 'private' && !checkItemAccess(item)"
|
||||
v-html="getSvgIcon('lock', 16)"
|
||||
class="absolute top-2 right-2 text-red-500 bg-white dark:bg-gray-800 rounded-full p-1"></span>
|
||||
<!-- Locked Item Overlay -->
|
||||
<div
|
||||
v-if="item.isLocked || (item.accessType === 'private' && !item.hasAccess)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-60 rounded-md z-10"
|
||||
>
|
||||
<div class="text-center p-2">
|
||||
<div class="bg-red-500 rounded-full p-2 inline-flex mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-white text-xs mb-2">Restricted Access</p>
|
||||
<button
|
||||
v-if="!item.accessRequestStatus || item.accessRequestStatus === 'rejected'"
|
||||
@click.stop="handleAccessRequest(item)"
|
||||
class="px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded"
|
||||
>
|
||||
Request Access
|
||||
</button>
|
||||
<div
|
||||
v-else-if="item.accessRequestStatus === 'pending'"
|
||||
class="px-2 py-1 bg-yellow-500 text-white text-xs rounded"
|
||||
>
|
||||
Request Pending
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1345,21 +1410,60 @@ const parentType = computed(() => {
|
||||
/>
|
||||
|
||||
<!-- Create New Dialog -->
|
||||
<DMSCreateNewDialog
|
||||
v-model:visible="showCreateDialog"
|
||||
:current-path="currentPath"
|
||||
:parent-type="parentType"
|
||||
@create="handleCreateNew"
|
||||
<DMSCreateNewDialog
|
||||
v-if="showCreateDialog"
|
||||
@close="showCreateDialog = false"
|
||||
@create="handleCreateNew"
|
||||
/>
|
||||
|
||||
<!-- Access Request Dialog -->
|
||||
<DMSAccessRequestDialog
|
||||
:visible="showAccessRequestDialog"
|
||||
<DMSAccessRequestDialog
|
||||
v-if="accessRequestItem"
|
||||
:item="accessRequestItem"
|
||||
@submit="handleAccessRequestSubmitted"
|
||||
@close="closeAccessRequestDialog"
|
||||
:visible="showAccessRequestDialog"
|
||||
@close="showAccessRequestDialog = false"
|
||||
@submit="submitAccessRequest"
|
||||
/>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<div
|
||||
v-if="requestSuccess"
|
||||
class="fixed bottom-4 right-4 bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded shadow-lg z-50"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm">Access request submitted successfully!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Notification -->
|
||||
<div
|
||||
v-if="requestError"
|
||||
class="fixed bottom-4 right-4 bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded shadow-lg z-50"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm">{{ requestError }}</p>
|
||||
<button
|
||||
@click="requestError = null"
|
||||
class="text-xs text-red-600 font-medium hover:text-red-500 mt-1"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
435
components/dms/workflows/DMSAccessRequestTracker.vue
Normal file
435
components/dms/workflows/DMSAccessRequestTracker.vue
Normal file
@ -0,0 +1,435 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
|
||||
// Store
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
timeRange: {
|
||||
type: String,
|
||||
default: '30days' // 7days, 30days, 90days, 365days
|
||||
},
|
||||
showPersonal: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDepartmental: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
// Component state
|
||||
const isLoading = ref(true);
|
||||
const accessRequests = ref([]);
|
||||
const departmentRequests = ref([]);
|
||||
const selectedTimeRange = ref(props.timeRange);
|
||||
|
||||
// Time ranges
|
||||
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' }
|
||||
];
|
||||
|
||||
// Methods
|
||||
const loadRequestData = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// In a real implementation, this would come from an API
|
||||
const [userRequests, deptRequests] = await Promise.all([
|
||||
dmsStore.getAccessRequests(props.userId),
|
||||
dmsStore.getDepartmentAccessRequests()
|
||||
]);
|
||||
|
||||
accessRequests.value = userRequests;
|
||||
departmentRequests.value = deptRequests;
|
||||
} catch (error) {
|
||||
console.error('Failed to load access request data:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Update time range
|
||||
const updateTimeRange = (range) => {
|
||||
selectedTimeRange.value = range;
|
||||
loadRequestData();
|
||||
};
|
||||
|
||||
// Format time duration in hours
|
||||
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`;
|
||||
}
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value) => {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
};
|
||||
|
||||
// Date formatters
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Computed metrics
|
||||
const personalMetrics = computed(() => {
|
||||
// Filter requests by the selected time range
|
||||
const requests = accessRequests.value.filter(req => {
|
||||
const requestDate = new Date(req.requestDate);
|
||||
const now = new Date();
|
||||
const daysAgo = (now - requestDate) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (selectedTimeRange.value === '7days') return daysAgo <= 7;
|
||||
if (selectedTimeRange.value === '30days') return daysAgo <= 30;
|
||||
if (selectedTimeRange.value === '90days') return daysAgo <= 90;
|
||||
if (selectedTimeRange.value === '365days') return daysAgo <= 365;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Extract only resolved requests (approved or rejected)
|
||||
const resolvedRequests = requests.filter(req =>
|
||||
req.status === 'approved' || req.status === 'rejected'
|
||||
);
|
||||
|
||||
// Total requests
|
||||
const totalRequests = requests.length;
|
||||
const pendingRequests = requests.filter(req => req.status === 'pending').length;
|
||||
const approvedRequests = requests.filter(req => req.status === 'approved').length;
|
||||
const rejectedRequests = requests.filter(req => req.status === 'rejected').length;
|
||||
|
||||
// Calculate response times
|
||||
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 responseTime = (responseDate - requestDate) / (1000 * 60 * 60); // in hours
|
||||
|
||||
totalResponseTime += responseTime;
|
||||
fastestResponse = Math.min(fastestResponse, responseTime);
|
||||
slowestResponse = Math.max(slowestResponse, responseTime);
|
||||
});
|
||||
|
||||
// Calculate averages
|
||||
const avgResponseTime = resolvedRequests.length > 0
|
||||
? totalResponseTime / resolvedRequests.length
|
||||
: 0;
|
||||
|
||||
// Calculate overdue
|
||||
const overdueRequests = requests.filter(req => {
|
||||
if (req.status !== 'pending') return false;
|
||||
|
||||
const deadline = new Date(req.targetResolutionTime);
|
||||
const now = new Date();
|
||||
return now > deadline;
|
||||
}).length;
|
||||
|
||||
const overduePercentage = totalRequests > 0
|
||||
? overdueRequests / totalRequests
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
pendingRequests,
|
||||
approvedRequests,
|
||||
rejectedRequests,
|
||||
avgResponseTime,
|
||||
fastestResponse: fastestResponse === Infinity ? 0 : fastestResponse,
|
||||
slowestResponse,
|
||||
overdueRequests,
|
||||
overduePercentage
|
||||
};
|
||||
});
|
||||
|
||||
const departmentMetrics = computed(() => {
|
||||
// Same calculations but for department requests
|
||||
const requests = departmentRequests.value.filter(req => {
|
||||
const requestDate = new Date(req.requestDate);
|
||||
const now = new Date();
|
||||
const daysAgo = (now - requestDate) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (selectedTimeRange.value === '7days') return daysAgo <= 7;
|
||||
if (selectedTimeRange.value === '30days') return daysAgo <= 30;
|
||||
if (selectedTimeRange.value === '90days') return daysAgo <= 90;
|
||||
if (selectedTimeRange.value === '365days') return daysAgo <= 365;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Extract only resolved requests (approved or rejected)
|
||||
const resolvedRequests = requests.filter(req =>
|
||||
req.status === 'approved' || req.status === 'rejected'
|
||||
);
|
||||
|
||||
// Total requests
|
||||
const totalRequests = requests.length;
|
||||
const pendingRequests = requests.filter(req => req.status === 'pending').length;
|
||||
const approvedRequests = requests.filter(req => req.status === 'approved').length;
|
||||
const rejectedRequests = requests.filter(req => req.status === 'rejected').length;
|
||||
|
||||
// Calculate response times
|
||||
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 responseTime = (responseDate - requestDate) / (1000 * 60 * 60); // in hours
|
||||
|
||||
totalResponseTime += responseTime;
|
||||
fastestResponse = Math.min(fastestResponse, responseTime);
|
||||
slowestResponse = Math.max(slowestResponse, responseTime);
|
||||
});
|
||||
|
||||
// Calculate averages
|
||||
const avgResponseTime = resolvedRequests.length > 0
|
||||
? totalResponseTime / resolvedRequests.length
|
||||
: 0;
|
||||
|
||||
// Calculate overdue
|
||||
const overdueRequests = requests.filter(req => {
|
||||
if (req.status !== 'pending') return false;
|
||||
|
||||
const deadline = new Date(req.targetResolutionTime);
|
||||
const now = new Date();
|
||||
return now > deadline;
|
||||
}).length;
|
||||
|
||||
const overduePercentage = totalRequests > 0
|
||||
? overdueRequests / totalRequests
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
pendingRequests,
|
||||
approvedRequests,
|
||||
rejectedRequests,
|
||||
avgResponseTime,
|
||||
fastestResponse: fastestResponse === Infinity ? 0 : fastestResponse,
|
||||
slowestResponse,
|
||||
overdueRequests,
|
||||
overduePercentage
|
||||
};
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadRequestData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dms-access-request-tracker">
|
||||
<!-- Header with time range selector -->
|
||||
<div class="flex flex-wrap items-center justify-between mb-4 gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Access Request Metrics</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Time range selector -->
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<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"
|
||||
:class="[
|
||||
selectedTimeRange === option.id
|
||||
? 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-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>
|
||||
|
||||
<!-- Refresh button -->
|
||||
<button
|
||||
@click="loadRequestData"
|
||||
class="inline-flex items-center px-2 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" 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>
|
||||
<span class="ml-1">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading metrics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Dashboard -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Personal Metrics -->
|
||||
<div v-if="props.showPersonal" class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-blue-50 dark:bg-blue-900/10 border-b border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-md font-medium text-blue-900 dark:text-blue-100">Personal Metrics</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">Your access request performance</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Status Metrics -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-900/10 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-gray-100">{{ personalMetrics.totalRequests }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Total Requests</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/10 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="text-xl font-bold text-yellow-700 dark:text-yellow-300">{{ personalMetrics.pendingRequests }}</div>
|
||||
<div class="text-sm text-yellow-600 dark:text-yellow-400">Pending</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/10 p-4 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div class="text-xl font-bold text-green-700 dark:text-green-300">{{ personalMetrics.approvedRequests }}</div>
|
||||
<div class="text-sm text-green-600 dark:text-green-400">Approved</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/10 p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="text-xl font-bold text-red-700 dark:text-red-300">{{ personalMetrics.rejectedRequests }}</div>
|
||||
<div class="text-sm text-red-600 dark:text-red-400">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Time Metrics -->
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Response Time Metrics</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-900/10 p-4 rounded-lg border border-indigo-200 dark:border-indigo-800">
|
||||
<div class="text-xl font-bold text-indigo-700 dark:text-indigo-300">{{ formatDuration(personalMetrics.avgResponseTime) }}</div>
|
||||
<div class="text-sm text-indigo-600 dark:text-indigo-400">Average Response</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/10 p-4 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div class="text-xl font-bold text-green-700 dark:text-green-300">{{ formatDuration(personalMetrics.fastestResponse) }}</div>
|
||||
<div class="text-sm text-green-600 dark:text-green-400">Fastest Response</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/10 p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="text-xl font-bold text-red-700 dark:text-red-300">{{ formatDuration(personalMetrics.slowestResponse) }}</div>
|
||||
<div class="text-sm text-red-600 dark:text-red-400">Slowest Response</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overdue Metrics -->
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Overdue Metrics</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-orange-50 dark:bg-orange-900/10 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div class="text-xl font-bold text-orange-700 dark:text-orange-300">{{ personalMetrics.overdueRequests }}</div>
|
||||
<div class="text-sm text-orange-600 dark:text-orange-400">Overdue Requests</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 dark:bg-orange-900/10 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div class="text-xl font-bold text-orange-700 dark:text-orange-300">{{ formatPercentage(personalMetrics.overduePercentage) }}</div>
|
||||
<div class="text-sm text-orange-600 dark:text-orange-400">Overdue Percentage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Department Metrics -->
|
||||
<div v-if="props.showDepartmental" class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-purple-50 dark:bg-purple-900/10 border-b border-purple-200 dark:border-purple-800">
|
||||
<h3 class="text-md font-medium text-purple-900 dark:text-purple-100">Department Metrics</h3>
|
||||
<p class="text-sm text-purple-700 dark:text-purple-300">Department-wide performance</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Status Metrics -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-900/10 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-gray-100">{{ departmentMetrics.totalRequests }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Total Requests</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/10 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="text-xl font-bold text-yellow-700 dark:text-yellow-300">{{ departmentMetrics.pendingRequests }}</div>
|
||||
<div class="text-sm text-yellow-600 dark:text-yellow-400">Pending</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/10 p-4 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div class="text-xl font-bold text-green-700 dark:text-green-300">{{ departmentMetrics.approvedRequests }}</div>
|
||||
<div class="text-sm text-green-600 dark:text-green-400">Approved</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/10 p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="text-xl font-bold text-red-700 dark:text-red-300">{{ departmentMetrics.rejectedRequests }}</div>
|
||||
<div class="text-sm text-red-600 dark:text-red-400">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Time Metrics -->
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Response Time Metrics</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-900/10 p-4 rounded-lg border border-indigo-200 dark:border-indigo-800">
|
||||
<div class="text-xl font-bold text-indigo-700 dark:text-indigo-300">{{ formatDuration(departmentMetrics.avgResponseTime) }}</div>
|
||||
<div class="text-sm text-indigo-600 dark:text-indigo-400">Average Response</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/10 p-4 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div class="text-xl font-bold text-green-700 dark:text-green-300">{{ formatDuration(departmentMetrics.fastestResponse) }}</div>
|
||||
<div class="text-sm text-green-600 dark:text-green-400">Fastest Response</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/10 p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="text-xl font-bold text-red-700 dark:text-red-300">{{ formatDuration(departmentMetrics.slowestResponse) }}</div>
|
||||
<div class="text-sm text-red-600 dark:text-red-400">Slowest Response</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overdue Metrics -->
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Overdue Metrics</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-orange-50 dark:bg-orange-900/10 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div class="text-xl font-bold text-orange-700 dark:text-orange-300">{{ departmentMetrics.overdueRequests }}</div>
|
||||
<div class="text-sm text-orange-600 dark:text-orange-400">Overdue Requests</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 dark:bg-orange-900/10 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div class="text-xl font-bold text-orange-700 dark:text-orange-300">{{ formatPercentage(departmentMetrics.overduePercentage) }}</div>
|
||||
<div class="text-sm text-orange-600 dark:text-orange-400">Overdue Percentage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dms-access-request-tracker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
539
components/dms/workflows/DMSApprovalQueue.vue
Normal file
539
components/dms/workflows/DMSApprovalQueue.vue
Normal file
@ -0,0 +1,539 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
import DMSAccessApprovalDialog from '~/components/dms/dialogs/DMSAccessApprovalDialog.vue';
|
||||
|
||||
// Store
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
showClosed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
filterByUser: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
maxItems: {
|
||||
type: Number,
|
||||
default: 20
|
||||
}
|
||||
});
|
||||
|
||||
// Component state
|
||||
const isLoading = ref(true);
|
||||
const statusFilter = ref('all'); // all, pending, approved, rejected
|
||||
const sortBy = ref('requestDate'); // requestDate, documentName, requester, deadline
|
||||
const sortOrder = ref('desc'); // asc, desc
|
||||
const accessRequests = ref([]);
|
||||
const selectedRequest = ref(null);
|
||||
const showApprovalDialog = ref(false);
|
||||
|
||||
// Status options
|
||||
const statusOptions = [
|
||||
{ id: 'all', label: 'All Requests', color: 'gray' },
|
||||
{ id: 'pending', label: 'Pending', color: 'yellow' },
|
||||
{ id: 'approved', label: 'Approved', color: 'green' },
|
||||
{ id: 'rejected', label: 'Rejected', color: 'red' }
|
||||
];
|
||||
|
||||
// Date formatters
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatDatetime = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Duration formatter (e.g. "2 days ago")
|
||||
const formatTimeAgo = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHour = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec} second${diffSec !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 7) {
|
||||
return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return formatDate(dateString);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate deadline status
|
||||
const getDeadlineStatus = (request) => {
|
||||
if (!request.targetResolutionTime) return { status: 'none', text: 'No deadline' };
|
||||
if (request.status !== 'pending') return { status: 'none', text: 'Completed' };
|
||||
|
||||
const deadline = new Date(request.targetResolutionTime);
|
||||
const now = new Date();
|
||||
const diffMs = deadline - now;
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
|
||||
if (diffHours < 0) {
|
||||
return {
|
||||
status: 'overdue',
|
||||
text: `Overdue by ${Math.abs(Math.floor(diffHours / 24))} days`
|
||||
};
|
||||
} else if (diffHours < 24) {
|
||||
return {
|
||||
status: 'urgent',
|
||||
text: `Due in ${Math.ceil(diffHours)} hours`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'normal',
|
||||
text: `Due in ${Math.ceil(diffHours / 24)} days`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Computed
|
||||
const filteredRequests = computed(() => {
|
||||
let result = [...accessRequests.value];
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter.value !== 'all') {
|
||||
result = result.filter(req => req.status === statusFilter.value);
|
||||
}
|
||||
|
||||
// Apply show closed filter
|
||||
if (!props.showClosed) {
|
||||
result = result.filter(req => req.status === 'pending');
|
||||
}
|
||||
|
||||
// Apply user filter
|
||||
if (props.filterByUser) {
|
||||
result = result.filter(req => req.requesterId === props.filterByUser || req.approverId === props.filterByUser);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
result.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy.value) {
|
||||
case 'requestDate':
|
||||
comparison = new Date(a.requestDate) - new Date(b.requestDate);
|
||||
break;
|
||||
case 'documentName':
|
||||
comparison = a.documentName.localeCompare(b.documentName);
|
||||
break;
|
||||
case 'requester':
|
||||
comparison = a.requesterName.localeCompare(b.requesterName);
|
||||
break;
|
||||
case 'deadline':
|
||||
// Sort by deadline with null values last
|
||||
if (!a.targetResolutionTime && !b.targetResolutionTime) comparison = 0;
|
||||
else if (!a.targetResolutionTime) comparison = 1;
|
||||
else if (!b.targetResolutionTime) comparison = -1;
|
||||
else comparison = new Date(a.targetResolutionTime) - new Date(b.targetResolutionTime);
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder.value === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Apply max items limit
|
||||
if (props.maxItems > 0) {
|
||||
result = result.slice(0, props.maxItems);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Get status badge class
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'rejected':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Get deadline class
|
||||
const getDeadlineClass = (deadlineStatus) => {
|
||||
switch (deadlineStatus) {
|
||||
case 'overdue':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'urgent':
|
||||
return 'text-orange-600 dark:text-orange-400';
|
||||
case 'normal':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Actions
|
||||
const loadAccessRequests = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// In a real implementation, this would come from an API
|
||||
// For now, use mock data from the store
|
||||
accessRequests.value = await dmsStore.getAccessRequests();
|
||||
} catch (error) {
|
||||
console.error('Failed to load access requests:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openApprovalDialog = (request) => {
|
||||
selectedRequest.value = request;
|
||||
showApprovalDialog.value = true;
|
||||
};
|
||||
|
||||
const handleApprovalAction = async (result) => {
|
||||
if (!selectedRequest.value) return;
|
||||
|
||||
if (result.action === 'approve') {
|
||||
await dmsStore.approveAccessRequest(selectedRequest.value.id, result.notes);
|
||||
} else if (result.action === 'reject') {
|
||||
await dmsStore.rejectAccessRequest(selectedRequest.value.id, result.notes);
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await loadAccessRequests();
|
||||
|
||||
// Close the dialog
|
||||
showApprovalDialog.value = false;
|
||||
selectedRequest.value = null;
|
||||
};
|
||||
|
||||
// Change sorting
|
||||
const toggleSort = (field) => {
|
||||
if (sortBy.value === field) {
|
||||
// Toggle sort direction
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// Set new sort field and default to ascending
|
||||
sortBy.value = field;
|
||||
sortOrder.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadAccessRequests();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dms-approval-queue">
|
||||
<!-- Header with filters -->
|
||||
<div class="flex flex-wrap items-center justify-between mb-4 gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Access Requests</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Status filter -->
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<button
|
||||
v-for="option in statusOptions"
|
||||
:key="option.id"
|
||||
@click="statusFilter = option.id"
|
||||
class="relative inline-flex items-center px-3 py-1.5 text-sm font-medium border transition-colors"
|
||||
:class="[
|
||||
statusFilter === option.id
|
||||
? `bg-${option.color}-100 border-${option.color}-300 text-${option.color}-800 dark:bg-${option.color}-900/20 dark:border-${option.color}-700 dark:text-${option.color}-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 === 'all' ? 'rounded-l-md' : '',
|
||||
option.id === 'rejected' ? 'rounded-r-md' : ''
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Refresh button -->
|
||||
<button
|
||||
@click="loadAccessRequests"
|
||||
class="inline-flex items-center px-2 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" 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>
|
||||
<span class="ml-1">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading access requests...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="filteredRequests.length === 0" class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" 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>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-1">No access requests found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">There are no access requests matching your current filters.</p>
|
||||
<rs-button
|
||||
@click="statusFilter = 'all'; loadAccessRequests()"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
View all requests
|
||||
</rs-button>
|
||||
</div>
|
||||
|
||||
<!-- Requests table -->
|
||||
<div v-else class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/20">
|
||||
<tr>
|
||||
<th
|
||||
@click="toggleSort('documentName')"
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span>Document</span>
|
||||
<svg
|
||||
v-if="sortBy === 'documentName'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click="toggleSort('requester')"
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span>Requester</span>
|
||||
<svg
|
||||
v-if="sortBy === 'requester'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click="toggleSort('requestDate')"
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span>Requested</span>
|
||||
<svg
|
||||
v-if="sortBy === 'requestDate'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click="toggleSort('deadline')"
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span>Deadline</span>
|
||||
<svg
|
||||
v-if="sortBy === 'deadline'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-1 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="request in filteredRequests"
|
||||
:key="request.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-900/10 transition-colors"
|
||||
>
|
||||
<!-- Document -->
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<!-- Document icon based on type -->
|
||||
<span class="flex-shrink-0 h-8 w-8 rounded-md bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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>
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.documentName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ request.documentPath }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Requester -->
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ request.requesterName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ request.requesterDepartment }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Request Date -->
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(request.requestDate) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatTimeAgo(request.requestDate) }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Deadline -->
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div
|
||||
v-if="request.targetResolutionTime"
|
||||
class="text-sm"
|
||||
:class="getDeadlineClass(getDeadlineStatus(request).status)"
|
||||
>
|
||||
{{ formatDate(request.targetResolutionTime) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs"
|
||||
:class="getDeadlineClass(getDeadlineStatus(request).status)"
|
||||
>
|
||||
{{ getDeadlineStatus(request).text }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full" :class="getStatusClass(request.status)">
|
||||
{{ request.status.charAt(0).toUpperCase() + request.status.slice(1) }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button
|
||||
v-if="request.status === 'pending'"
|
||||
@click="openApprovalDialog(request)"
|
||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Approval Dialog -->
|
||||
<DMSAccessApprovalDialog
|
||||
v-if="selectedRequest"
|
||||
:visible="showApprovalDialog"
|
||||
:request="selectedRequest"
|
||||
@close="showApprovalDialog = false"
|
||||
@submit="handleApprovalAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dms-approval-queue {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Dynamic color styles for table header hover effect */
|
||||
.cursor-pointer {
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
</style>
|
@ -10,12 +10,6 @@ export default [
|
||||
"child": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"title": "Access Requests",
|
||||
"path": "/dms/access-requests",
|
||||
"icon": "ic:outline-security",
|
||||
"child": []
|
||||
},
|
||||
{
|
||||
"title": "Settings",
|
||||
"path": "/dms/settings",
|
||||
|
236
pages/dms/access-management.vue
Normal file
236
pages/dms/access-management.vue
Normal file
@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
title: "Access Management",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "DMS",
|
||||
path: "/dms",
|
||||
},
|
||||
{
|
||||
name: "Access Management",
|
||||
path: "/dms/access-management",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Lazy load components to improve initial page load
|
||||
const DMSApprovalQueue = defineAsyncComponent(() =>
|
||||
import('~/components/dms/workflows/DMSApprovalQueue.vue')
|
||||
);
|
||||
const DMSAccessRequestTracker = defineAsyncComponent(() =>
|
||||
import('~/components/dms/workflows/DMSAccessRequestTracker.vue')
|
||||
);
|
||||
|
||||
// Store
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// Component state
|
||||
const activeTab = ref('requests');
|
||||
const isLoading = ref(true);
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
// User role and permissions
|
||||
const userPermissions = ref({
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canViewAll: false
|
||||
});
|
||||
|
||||
// Tabs definition
|
||||
const tabs = [
|
||||
{
|
||||
id: 'requests',
|
||||
label: 'Access Requests',
|
||||
icon: 'lock-access',
|
||||
description: 'Manage pending and historical access requests'
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: 'Performance Metrics',
|
||||
icon: 'chart',
|
||||
description: 'Track access request KPIs and time metrics'
|
||||
}
|
||||
];
|
||||
|
||||
// Methods
|
||||
const changeTab = (tabId) => {
|
||||
activeTab.value = tabId;
|
||||
};
|
||||
|
||||
// Load user permissions
|
||||
const loadUserPermissions = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// In a real implementation, this would be fetched from an authentication service
|
||||
// For now we'll simulate it with the store
|
||||
const userId = dmsStore.currentUser.id;
|
||||
const userRole = dmsStore.currentUser.role;
|
||||
|
||||
// Get permissions from the store (simulated Authentik integration)
|
||||
const permissions = await dmsStore.getRbacPermissions(userId);
|
||||
|
||||
// Set the permissions
|
||||
userPermissions.value = {
|
||||
canApprove: permissions.permissions.accessRequests.approve || false,
|
||||
canReject: permissions.permissions.accessRequests.reject || false,
|
||||
canViewAll: permissions.permissions.accessRequests.viewAll || false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to load user permissions:', error);
|
||||
hasError.value = true;
|
||||
errorMessage.value = 'Failed to load user permissions. Please try again.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Get SVG icon
|
||||
const getSvgIcon = (iconName) => {
|
||||
const icons = {
|
||||
'lock-access': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
|
||||
'chart': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>`,
|
||||
'user': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`
|
||||
};
|
||||
|
||||
return icons[iconName] || '';
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
loadUserPermissions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dms-access-management">
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<rs-card class="h-full">
|
||||
<template #body>
|
||||
<div class="h-full flex flex-col">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading access management...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="hasError" class="flex items-center justify-center h-full">
|
||||
<div class="text-center p-6">
|
||||
<div class="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 class="text-xl font-semibold text-red-600 mb-2">Error Loading Access Management</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ errorMessage }}</p>
|
||||
<rs-button @click="loadUserPermissions" variant="primary">
|
||||
Retry
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="h-full flex flex-col">
|
||||
<!-- Header with user information -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mr-3">
|
||||
<span v-html="getSvgIcon('user')" class="text-blue-600 dark:text-blue-400"></span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ dmsStore.currentUser.name }}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ dmsStore.currentUser.department }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
v-if="userPermissions.canApprove"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||||
>
|
||||
Approver
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="userPermissions.canViewAll"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
Administrator
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="changeTab(tab.id)"
|
||||
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span v-html="getSvgIcon(tab.icon)" class="mr-2"></span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Description -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-2">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ tabs.find(t => t.id === activeTab)?.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<!-- Access Requests Tab -->
|
||||
<div v-if="activeTab === 'requests'" class="h-full">
|
||||
<DMSApprovalQueue
|
||||
:showClosed="userPermissions.canViewAll"
|
||||
:filterByUser="userPermissions.canViewAll ? null : dmsStore.currentUser.id"
|
||||
:maxItems="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Tab -->
|
||||
<div v-else-if="activeTab === 'metrics'" class="h-full">
|
||||
<DMSAccessRequestTracker
|
||||
:userId="userPermissions.canViewAll ? null : dmsStore.currentUser.id"
|
||||
:timeRange="'30days'"
|
||||
:showPersonal="true"
|
||||
:showDepartmental="userPermissions.canViewAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dms-access-management {
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
</style>
|
@ -181,6 +181,41 @@ onMounted(() => {
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="content-area flex-1">
|
||||
<!-- Action Buttons -->
|
||||
<div class="px-6 py-3 bg-white dark:bg-gray-900/10 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div class="flex space-x-2">
|
||||
<!-- Placeholder for future actions -->
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<!-- Access Management Button -->
|
||||
<NuxtLink
|
||||
to="/dms/access-management"
|
||||
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 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
Access Management
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Role Management Button -->
|
||||
<NuxtLink
|
||||
to="/dms/role-management"
|
||||
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 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
Role Management
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DMS Explorer Component -->
|
||||
<component
|
||||
v-if="componentsLoaded && DMSExplorer"
|
||||
|
585
pages/dms/role-management.vue
Normal file
585
pages/dms/role-management.vue
Normal file
@ -0,0 +1,585 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDmsStore } from '~/stores/dms';
|
||||
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
title: "Role Management",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
breadcrumb: [
|
||||
{
|
||||
name: "DMS",
|
||||
path: "/dms",
|
||||
},
|
||||
{
|
||||
name: "Role Management",
|
||||
path: "/dms/role-management",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Store
|
||||
const dmsStore = useDmsStore();
|
||||
|
||||
// State variables
|
||||
const isLoading = ref(true);
|
||||
const users = ref([]);
|
||||
const roles = ref([]);
|
||||
const activeTab = ref('users');
|
||||
const searchQuery = ref('');
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
// Filtered users
|
||||
const filteredUsers = computed(() => {
|
||||
if (!searchQuery.value) return users.value;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return users.value.filter(user =>
|
||||
user.name.toLowerCase().includes(query) ||
|
||||
user.email.toLowerCase().includes(query) ||
|
||||
user.department.toLowerCase().includes(query) ||
|
||||
user.roles.some(role => role.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadUsers = async () => {
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
// This would be an actual API call in production
|
||||
// For demo, we'll simulate it with mock data
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
users.value = [
|
||||
{
|
||||
id: 'user1',
|
||||
name: 'Aiman Fakhrullah',
|
||||
email: 'aiman@example.com',
|
||||
department: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
roles: ['engineer'],
|
||||
lastLogin: '2023-12-15T08:30:00Z',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'user2',
|
||||
name: 'Ahmad Zaki',
|
||||
email: 'ahmad@example.com',
|
||||
department: 'JKR Bahagian Kewangan',
|
||||
roles: ['finance', 'approver'],
|
||||
lastLogin: '2023-12-14T14:45:00Z',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'user3',
|
||||
name: 'Siti Aminah',
|
||||
email: 'siti@example.com',
|
||||
department: 'JKR Cawangan Kuala Terengganu',
|
||||
roles: ['manager', 'approver'],
|
||||
lastLogin: '2023-12-13T09:15:00Z',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'admin1',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
department: 'IT Department',
|
||||
roles: ['admin'],
|
||||
lastLogin: '2023-12-15T10:00:00Z',
|
||||
status: 'active'
|
||||
}
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
hasError.value = true;
|
||||
errorMessage.value = 'Failed to load users. Please try again.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
|
||||
try {
|
||||
// This would be an actual API call to Authentik in production
|
||||
// For demo, we'll simulate it with mock data
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
roles.value = [
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Administrator',
|
||||
description: 'Full system access',
|
||||
userCount: 1,
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
approve: true,
|
||||
reject: true,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true
|
||||
},
|
||||
accessRequests: {
|
||||
approve: true,
|
||||
reject: true,
|
||||
viewAll: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'manager',
|
||||
name: 'Manager',
|
||||
description: 'Department management',
|
||||
userCount: 1,
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
approve: true,
|
||||
reject: true,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: true,
|
||||
reject: true,
|
||||
viewAll: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'engineer',
|
||||
name: 'Engineer',
|
||||
description: 'Engineering staff',
|
||||
userCount: 1,
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
approve: false,
|
||||
reject: false,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: false,
|
||||
reject: false,
|
||||
viewAll: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
name: 'Finance',
|
||||
description: 'Finance department staff',
|
||||
userCount: 1,
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
approve: false,
|
||||
reject: false,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: false,
|
||||
reject: false,
|
||||
viewAll: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'approver',
|
||||
name: 'Approver',
|
||||
description: 'Can approve access requests',
|
||||
userCount: 2,
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: false,
|
||||
delete: false,
|
||||
approve: true,
|
||||
reject: true,
|
||||
download: false
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: true,
|
||||
reject: true,
|
||||
viewAll: true
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load roles:', error);
|
||||
hasError.value = true;
|
||||
errorMessage.value = 'Failed to load roles. Please try again.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (tab) => {
|
||||
activeTab.value = tab;
|
||||
|
||||
if (tab === 'users' && users.value.length === 0) {
|
||||
loadUsers();
|
||||
} else if (tab === 'roles' && roles.value.length === 0) {
|
||||
loadRoles();
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return 'Never';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Icons
|
||||
const getSvgIcon = (iconName) => {
|
||||
const icons = {
|
||||
'users': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
|
||||
'roles': `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>`,
|
||||
'search': `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
|
||||
'add': `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
|
||||
'edit': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
|
||||
'trash': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`
|
||||
};
|
||||
|
||||
return icons[iconName] || '';
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dms-role-management">
|
||||
<LayoutsBreadcrumb />
|
||||
|
||||
<rs-card class="h-full">
|
||||
<template #body>
|
||||
<div class="h-full flex flex-col">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading role management...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="hasError" class="flex items-center justify-center h-full">
|
||||
<div class="text-center p-6">
|
||||
<div class="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 class="text-xl font-semibold text-red-600 mb-2">Error Loading Data</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ errorMessage }}</p>
|
||||
<rs-button @click="activeTab === 'users' ? loadUsers() : loadRoles()" variant="primary">
|
||||
Retry
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div v-else class="h-full flex flex-col">
|
||||
<!-- Header with authentik info -->
|
||||
<div class="bg-indigo-50 dark:bg-indigo-900/20 border-b border-indigo-200 dark:border-indigo-800">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-600 dark:text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-indigo-900 dark:text-indigo-100">Authentik Integration</h2>
|
||||
<p class="text-sm text-indigo-600 dark:text-indigo-300">Role-Based Access Control Management</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="px-6">
|
||||
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in [{ id: 'users', label: 'Users', icon: 'users' }, { id: 'roles', label: 'Roles', icon: 'roles' }]"
|
||||
:key="tab.id"
|
||||
@click="switchTab(tab.id)"
|
||||
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span v-html="getSvgIcon(tab.icon)" class="mr-2"></span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span v-html="getSvgIcon('search')" class="text-gray-400 dark:text-gray-500"></span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
class="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 w-64"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div>
|
||||
<rs-button variant="primary" class="flex items-center">
|
||||
<span v-html="getSvgIcon('add')" class="mr-1.5"></span>
|
||||
<span>{{ activeTab === 'users' ? 'Add User' : 'Add Role' }}</span>
|
||||
</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<!-- Users Tab -->
|
||||
<div v-if="activeTab === 'users'" class="h-full">
|
||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/10">
|
||||
<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">
|
||||
User
|
||||
</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">
|
||||
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">
|
||||
Roles
|
||||
</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">
|
||||
Last Login
|
||||
</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">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="user in filteredUsers" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-gray-900/10">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
{{ user.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="max-w-xs truncate">{{ user.department }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="role in user.roles"
|
||||
:key="role"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="{
|
||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-300': role === 'admin',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': role === 'manager',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300': role === 'engineer',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': role === 'finance',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300': role === 'approver'
|
||||
}"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatDateTime(user.lastLogin) }}
|
||||
</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"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300': user.status === 'active',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300': user.status === 'pending',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300': user.status === 'inactive'
|
||||
}"
|
||||
>
|
||||
{{ user.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-3">
|
||||
<span v-html="getSvgIcon('edit')"></span>
|
||||
</button>
|
||||
<button class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||
<span v-html="getSvgIcon('trash')"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Tab -->
|
||||
<div v-else-if="activeTab === 'roles'" class="h-full">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ role.name }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ role.description }}</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
|
||||
<span v-html="getSvgIcon('edit')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Document Permissions</h4>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(value, key) in role.permissions.documents"
|
||||
:key="`doc-${key}`"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Cabinet Permissions</h4>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(value, key) in role.permissions.cabinets"
|
||||
:key="`cab-${key}`"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">Access Request Permissions</h4>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(value, key) in role.permissions.accessRequests"
|
||||
:key="`acc-${key}`"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="value ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400 line-through'"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-900/10 text-right">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ role.userCount }} {{ role.userCount === 1 ? 'user' : 'users' }} with this role
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dms-role-management {
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
</style>
|
774
stores/dms.js
774
stores/dms.js
@ -30,6 +30,224 @@ export const useDmsStore = defineStore('dms', {
|
||||
{ id: 'rejected', name: 'Rejected', color: 'red' }
|
||||
],
|
||||
|
||||
// Access requests tracking
|
||||
accessRequests: [
|
||||
{
|
||||
id: 'req1',
|
||||
documentId: 'private-file1',
|
||||
documentName: 'Annual_Budget_2024_CONFIDENTIAL.xlsx',
|
||||
documentPath: '/private-cabinet1/private-drawer1/Annual_Budget_2024_CONFIDENTIAL.xlsx',
|
||||
requesterId: 'user1',
|
||||
requesterName: 'Aiman Fakhrullah',
|
||||
requesterEmail: 'aiman@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: 'user2',
|
||||
approverName: 'Ahmad Zaki',
|
||||
approverDepartment: 'JKR Bahagian Kewangan',
|
||||
requestDate: '2023-12-01T09:30:00Z',
|
||||
targetResolutionTime: '2023-12-03T09:30:00Z', // 48 hours SLA
|
||||
responseDate: '2023-12-02T14:15:00Z',
|
||||
status: 'approved',
|
||||
accessType: 'view',
|
||||
accessDuration: '7 days',
|
||||
justification: 'Need to review budget allocations for the upcoming bridge construction project',
|
||||
notes: 'Approved for view-only access. Please ensure confidentiality is maintained.'
|
||||
},
|
||||
{
|
||||
id: 'req2',
|
||||
documentId: 'private-drawer1',
|
||||
documentName: 'Budget & Financial Reports',
|
||||
documentPath: '/private-cabinet1/Budget & Financial Reports',
|
||||
requesterId: 'user1',
|
||||
requesterName: 'Aiman Fakhrullah',
|
||||
requesterEmail: 'aiman@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: '2023-12-10T11:45:00Z',
|
||||
targetResolutionTime: '2023-12-12T11:45:00Z', // 48 hours SLA
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType: 'download',
|
||||
accessDuration: '30 days',
|
||||
justification: 'Need to analyze financial reports for the department annual review',
|
||||
notes: null
|
||||
},
|
||||
{
|
||||
id: 'req3',
|
||||
documentId: 'jkr-kuala-terengganu',
|
||||
documentName: 'JKR Cawangan Kuala Terengganu, Terengganu',
|
||||
documentPath: '/private-cabinets/JKR Cawangan Kuala Terengganu, Terengganu',
|
||||
requesterId: 'user1',
|
||||
requesterName: 'Aiman Fakhrullah',
|
||||
requesterEmail: 'aiman@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: 'user3',
|
||||
approverName: 'Siti Aminah',
|
||||
approverDepartment: 'JKR Cawangan Kuala Terengganu',
|
||||
requestDate: '2023-12-05T15:20:00Z',
|
||||
targetResolutionTime: '2023-12-07T15:20:00Z', // 48 hours SLA
|
||||
responseDate: '2023-12-08T10:05:00Z', // Overdue response
|
||||
status: 'rejected',
|
||||
accessType: 'full',
|
||||
accessDuration: '90 days',
|
||||
justification: 'Need full access for the joint infrastructure project between Kota Bharu and Kuala Terengganu branches',
|
||||
notes: 'Request denied. Please have your department head contact our director for special authorization.'
|
||||
},
|
||||
{
|
||||
id: 'req4',
|
||||
documentId: 'jkr-batu-kawan',
|
||||
documentName: 'JKR Cawangan Batu Kawan, Penang',
|
||||
documentPath: '/private-cabinets/JKR Cawangan Batu Kawan, Penang',
|
||||
requesterId: 'user1',
|
||||
requesterName: 'Aiman Fakhrullah',
|
||||
requesterEmail: 'aiman@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: '2023-11-28T09:15:00Z',
|
||||
targetResolutionTime: '2023-11-30T09:15:00Z', // 48 hours SLA
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType: 'view',
|
||||
accessDuration: '14 days',
|
||||
justification: 'Need to review similar projects in Batu Kawan for reference',
|
||||
notes: null
|
||||
},
|
||||
{
|
||||
id: 'req5',
|
||||
documentId: 'jkr-arkitek',
|
||||
documentName: 'JKR Cawangan Arkitek',
|
||||
documentPath: '/private-cabinets/JKR Cawangan Arkitek',
|
||||
requesterId: 'user1',
|
||||
requesterName: 'Aiman Fakhrullah',
|
||||
requesterEmail: 'aiman@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: '2023-12-15T14:30:00Z',
|
||||
targetResolutionTime: '2023-12-17T14:30:00Z', // 48 hours SLA
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType: 'view',
|
||||
accessDuration: '30 days',
|
||||
justification: 'Need to consult architectural plans for the new government complex',
|
||||
notes: null
|
||||
}
|
||||
],
|
||||
|
||||
// Department access requests (for KPI tracking)
|
||||
departmentAccessRequests: [
|
||||
{
|
||||
id: 'dept-req1',
|
||||
documentId: 'finance-reports-2023',
|
||||
documentName: 'Finance Reports 2023',
|
||||
documentPath: '/private-cabinet1/finance-reports-2023',
|
||||
requesterId: 'user4',
|
||||
requesterName: 'Ibrahim Hassan',
|
||||
requesterEmail: 'ibrahim@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: 'user5',
|
||||
approverName: 'Fatimah Abdullah',
|
||||
approverDepartment: 'JKR Bahagian Kewangan',
|
||||
requestDate: '2023-12-02T10:30:00Z',
|
||||
targetResolutionTime: '2023-12-04T10:30:00Z',
|
||||
responseDate: '2023-12-03T11:45:00Z',
|
||||
status: 'approved',
|
||||
accessType: 'view',
|
||||
accessDuration: '14 days',
|
||||
justification: 'Need to review financial reports for project planning',
|
||||
notes: 'Approved with standard view-only restrictions'
|
||||
},
|
||||
{
|
||||
id: 'dept-req2',
|
||||
documentId: 'hr-policies-2023',
|
||||
documentName: 'HR Policies 2023',
|
||||
documentPath: '/private-cabinet2/hr-policies-2023',
|
||||
requesterId: 'user6',
|
||||
requesterName: 'Nurul Huda',
|
||||
requesterEmail: 'nurul@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: 'user7',
|
||||
approverName: 'Omar Ali',
|
||||
approverDepartment: 'JKR Bahagian HR',
|
||||
requestDate: '2023-12-05T09:15:00Z',
|
||||
targetResolutionTime: '2023-12-07T09:15:00Z',
|
||||
responseDate: '2023-12-09T16:30:00Z', // Overdue
|
||||
status: 'approved',
|
||||
accessType: 'download',
|
||||
accessDuration: '30 days',
|
||||
justification: 'Need to implement new HR policies in our department',
|
||||
notes: 'Approved with delayed response due to verification requirements'
|
||||
},
|
||||
{
|
||||
id: 'dept-req3',
|
||||
documentId: 'strategic-plan-2024',
|
||||
documentName: 'Strategic Plan 2024',
|
||||
documentPath: '/private-cabinet3/strategic-plan-2024',
|
||||
requesterId: 'user8',
|
||||
requesterName: 'Hassan Ibrahim',
|
||||
requesterEmail: 'hassan@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: 'user9',
|
||||
approverName: 'Zainab Mohamed',
|
||||
approverDepartment: 'JKR Pengarah',
|
||||
requestDate: '2023-12-08T11:00:00Z',
|
||||
targetResolutionTime: '2023-12-10T11:00:00Z',
|
||||
responseDate: '2023-12-09T09:45:00Z',
|
||||
status: 'rejected',
|
||||
accessType: 'full',
|
||||
accessDuration: '90 days',
|
||||
justification: 'Need full access to implement strategic initiatives',
|
||||
notes: 'Rejected due to insufficient clearance level. Please request through department head.'
|
||||
},
|
||||
{
|
||||
id: 'dept-req4',
|
||||
documentId: 'project-tendering-guidelines',
|
||||
documentName: 'Project Tendering Guidelines',
|
||||
documentPath: '/private-cabinet4/project-tendering-guidelines',
|
||||
requesterId: 'user10',
|
||||
requesterName: 'Razak Ismail',
|
||||
requesterEmail: 'razak@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: '2023-12-12T14:15:00Z',
|
||||
targetResolutionTime: '2023-12-14T14:15:00Z',
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType: 'view',
|
||||
accessDuration: '7 days',
|
||||
justification: 'Need to review tendering guidelines for upcoming project',
|
||||
notes: null
|
||||
},
|
||||
{
|
||||
id: 'dept-req5',
|
||||
documentId: 'audit-reports-2023',
|
||||
documentName: 'Audit Reports 2023',
|
||||
documentPath: '/private-cabinet5/audit-reports-2023',
|
||||
requesterId: 'user11',
|
||||
requesterName: 'Aminah Zainal',
|
||||
requesterEmail: 'aminah@example.com',
|
||||
requesterDepartment: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu',
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: '2023-12-14T10:30:00Z',
|
||||
targetResolutionTime: '2023-12-16T10:30:00Z',
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType: 'download',
|
||||
accessDuration: '14 days',
|
||||
justification: 'Need to review audit findings for compliance',
|
||||
notes: null
|
||||
}
|
||||
],
|
||||
|
||||
// Items state - in production this would be loaded from API
|
||||
cabinets: [
|
||||
{
|
||||
@ -268,75 +486,24 @@ export const useDmsStore = defineStore('dms', {
|
||||
}
|
||||
],
|
||||
|
||||
// Access requests
|
||||
accessRequests: [
|
||||
{
|
||||
id: 'req1',
|
||||
userId: 'user1',
|
||||
userName: 'Aiman Fakhrullah',
|
||||
cabinetId: 'jkr-batu-kawan',
|
||||
cabinetName: 'JKR Cawangan Batu Kawan, Penang',
|
||||
requestDate: '2023-06-15',
|
||||
status: 'pending',
|
||||
reason: 'Need access for project collaboration'
|
||||
},
|
||||
{
|
||||
id: 'req2',
|
||||
userId: 'user1',
|
||||
userName: 'Aiman Fakhrullah',
|
||||
cabinetId: 'jkr-kuala-terengganu',
|
||||
cabinetName: 'JKR Cawangan Kuala Terengganu, Terengganu',
|
||||
requestDate: '2023-06-16',
|
||||
status: 'pending',
|
||||
reason: 'Required for cross-department coordination'
|
||||
},
|
||||
{
|
||||
id: 'req3',
|
||||
userId: 'user1',
|
||||
userName: 'Aiman Fakhrullah',
|
||||
cabinetId: 'jkr-arkitek',
|
||||
cabinetName: 'JKR Cawangan Arkitek',
|
||||
requestDate: '2023-06-17',
|
||||
status: 'pending',
|
||||
reason: 'Need architectural plans for current project'
|
||||
},
|
||||
{
|
||||
id: 'req4',
|
||||
userId: 'user1',
|
||||
userName: 'Aiman Fakhrullah',
|
||||
cabinetId: 'jkr-putrajaya',
|
||||
cabinetName: 'JKR Cawangan Putrajaya',
|
||||
requestDate: '2023-06-01',
|
||||
status: 'rejected',
|
||||
reason: 'Need access to headquarters documents',
|
||||
rejectionReason: 'Access restricted to headquarters staff only'
|
||||
}
|
||||
],
|
||||
|
||||
// Selected item
|
||||
selectedItem: null,
|
||||
|
||||
// View settings
|
||||
viewMode: 'list', // list, grid, details
|
||||
sortBy: 'name',
|
||||
sortDirection: 'asc',
|
||||
|
||||
// User permissions - would be loaded from auth service
|
||||
userPermissions: {
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canManageAccess: true
|
||||
},
|
||||
|
||||
// Flags
|
||||
isLoading: false,
|
||||
showFileViewer: false,
|
||||
viewerDocument: null,
|
||||
searchQuery: '',
|
||||
// Search state
|
||||
searchResults: [],
|
||||
searchQuery: '',
|
||||
|
||||
// Settings integration
|
||||
// Upload state
|
||||
uploadProgress: 0,
|
||||
currentUploads: [],
|
||||
|
||||
// View state
|
||||
viewMode: 'list', // list, grid, details
|
||||
sortBy: 'name', // name, modified, size, type
|
||||
sortDirection: 'asc', // asc, desc
|
||||
|
||||
// File viewer state
|
||||
fileViewerOpen: false,
|
||||
currentDocument: null,
|
||||
|
||||
// System settings
|
||||
systemSettings: {
|
||||
upload: {
|
||||
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png'],
|
||||
@ -521,8 +688,44 @@ export const useDmsStore = defineStore('dms', {
|
||||
},
|
||||
|
||||
// Get pending access requests
|
||||
pendingAccessRequests: (state) => {
|
||||
return state.accessRequests.filter(req => req.status === 'pending');
|
||||
pendingAccessRequests() {
|
||||
return this.accessRequests.filter(req => req.status === 'pending');
|
||||
},
|
||||
|
||||
approvedAccessRequests() {
|
||||
return this.accessRequests.filter(req => req.status === 'approved');
|
||||
},
|
||||
|
||||
rejectedAccessRequests() {
|
||||
return this.accessRequests.filter(req => req.status === 'rejected');
|
||||
},
|
||||
|
||||
overdueAccessRequests() {
|
||||
const now = new Date();
|
||||
return this.accessRequests.filter(req => {
|
||||
if (req.status !== 'pending') return false;
|
||||
|
||||
const deadline = new Date(req.targetResolutionTime);
|
||||
return now > deadline;
|
||||
});
|
||||
},
|
||||
|
||||
// Getter for average response time
|
||||
averageResponseTime() {
|
||||
const resolvedRequests = this.accessRequests.filter(
|
||||
req => req.status === 'approved' || req.status === 'rejected'
|
||||
);
|
||||
|
||||
if (resolvedRequests.length === 0) return 0;
|
||||
|
||||
const totalResponseTime = resolvedRequests.reduce((total, req) => {
|
||||
const requestDate = new Date(req.requestDate);
|
||||
const responseDate = new Date(req.responseDate);
|
||||
const responseTimeHours = (responseDate - requestDate) / (1000 * 60 * 60);
|
||||
return total + responseTimeHours;
|
||||
}, 0);
|
||||
|
||||
return totalResponseTime / resolvedRequests.length;
|
||||
}
|
||||
},
|
||||
|
||||
@ -1151,46 +1354,55 @@ export const useDmsStore = defineStore('dms', {
|
||||
},
|
||||
|
||||
// Access request functionality
|
||||
async requestAccess(itemId, accessLevel, justification, duration = '7 days') {
|
||||
async requestAccess(itemId, accessType, justification, duration = '7 days') {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
// Mock API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// Generate a unique request ID
|
||||
const requestId = `req${Date.now()}`;
|
||||
// Find the item being requested
|
||||
const item = this.findItemById(itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new Error(`Item with id ${itemId} not found`);
|
||||
}
|
||||
|
||||
// Calculate target resolution time (48 hours SLA)
|
||||
const requestDate = new Date();
|
||||
const targetResolutionTime = new Date(requestDate);
|
||||
targetResolutionTime.setHours(targetResolutionTime.getHours() + 48);
|
||||
|
||||
// Create new access request
|
||||
const newRequest = {
|
||||
id: requestId,
|
||||
userId: 'current-user-id', // Would come from auth store
|
||||
userName: 'Current User', // Would come from auth store
|
||||
itemId: itemId,
|
||||
accessLevel: accessLevel,
|
||||
justification: justification,
|
||||
duration: duration,
|
||||
requestDate: new Date().toISOString().split('T')[0],
|
||||
status: 'pending'
|
||||
id: `req-${Date.now()}`,
|
||||
documentId: item.id,
|
||||
documentName: item.name,
|
||||
documentPath: this.getItemPath(item),
|
||||
requesterId: this.currentUser.id,
|
||||
requesterName: this.currentUser.name,
|
||||
requesterEmail: this.currentUser.email,
|
||||
requesterDepartment: this.currentUser.department,
|
||||
approverId: null,
|
||||
approverName: null,
|
||||
approverDepartment: null,
|
||||
requestDate: requestDate.toISOString(),
|
||||
targetResolutionTime: targetResolutionTime.toISOString(),
|
||||
responseDate: null,
|
||||
status: 'pending',
|
||||
accessType,
|
||||
accessDuration: duration,
|
||||
justification,
|
||||
notes: null
|
||||
};
|
||||
|
||||
// Add to access requests
|
||||
// Add the request to the store
|
||||
this.accessRequests.push(newRequest);
|
||||
|
||||
// Update the item's access request status (for mock data)
|
||||
// In production, this would be handled server-side
|
||||
const updateItemStatus = (items, id) => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
item.accessRequestStatus = 'pending';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try to find and update the item in the mock data arrays
|
||||
// This is a simplified approach for demo purposes
|
||||
// Update the item's access request status if it's a cabinet
|
||||
if (item.type === 'cabinet' || item.type === 'cabinet-group') {
|
||||
this.updateCabinetAccessStatus(itemId, 'pending');
|
||||
}
|
||||
|
||||
return newRequest;
|
||||
} catch (error) {
|
||||
@ -1199,6 +1411,374 @@ export const useDmsStore = defineStore('dms', {
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Get access requests (can filter by user)
|
||||
async getAccessRequests(userId = null) {
|
||||
// Simulate API delay for realistic testing
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (userId) {
|
||||
return this.accessRequests.filter(req => req.requesterId === userId);
|
||||
}
|
||||
|
||||
return this.accessRequests;
|
||||
},
|
||||
|
||||
// Get department access requests
|
||||
async getDepartmentAccessRequests() {
|
||||
// Simulate API delay for realistic testing
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
return this.departmentAccessRequests;
|
||||
},
|
||||
|
||||
// Approve an access request
|
||||
async approveAccessRequest(requestId, notes = '') {
|
||||
// Simulate API delay for realistic testing
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const requestIndex = this.accessRequests.findIndex(req => req.id === requestId);
|
||||
|
||||
if (requestIndex === -1) {
|
||||
throw new Error(`Request with id ${requestId} not found`);
|
||||
}
|
||||
|
||||
// Update the request
|
||||
const request = this.accessRequests[requestIndex];
|
||||
const updatedRequest = {
|
||||
...request,
|
||||
approverId: this.currentUser.id,
|
||||
approverName: this.currentUser.name,
|
||||
approverDepartment: this.currentUser.department,
|
||||
responseDate: new Date().toISOString(),
|
||||
status: 'approved',
|
||||
notes
|
||||
};
|
||||
|
||||
// Update in store
|
||||
this.accessRequests[requestIndex] = updatedRequest;
|
||||
|
||||
// Update the item's access status if it's a cabinet
|
||||
if (request.documentId) {
|
||||
const item = this.findItemById(request.documentId);
|
||||
if (item && (item.type === 'cabinet' || item.type === 'cabinet-group')) {
|
||||
this.updateCabinetAccessStatus(request.documentId, 'approved');
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRequest;
|
||||
},
|
||||
|
||||
// Reject an access request
|
||||
async rejectAccessRequest(requestId, notes = '') {
|
||||
// Simulate API delay for realistic testing
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const requestIndex = this.accessRequests.findIndex(req => req.id === requestId);
|
||||
|
||||
if (requestIndex === -1) {
|
||||
throw new Error(`Request with id ${requestId} not found`);
|
||||
}
|
||||
|
||||
// Update the request
|
||||
const request = this.accessRequests[requestIndex];
|
||||
const updatedRequest = {
|
||||
...request,
|
||||
approverId: this.currentUser.id,
|
||||
approverName: this.currentUser.name,
|
||||
approverDepartment: this.currentUser.department,
|
||||
responseDate: new Date().toISOString(),
|
||||
status: 'rejected',
|
||||
notes
|
||||
};
|
||||
|
||||
// Update in store
|
||||
this.accessRequests[requestIndex] = updatedRequest;
|
||||
|
||||
// Update the item's access status if it's a cabinet
|
||||
if (request.documentId) {
|
||||
const item = this.findItemById(request.documentId);
|
||||
if (item && (item.type === 'cabinet' || item.type === 'cabinet-group')) {
|
||||
this.updateCabinetAccessStatus(request.documentId, 'rejected');
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRequest;
|
||||
},
|
||||
|
||||
// Get KPI metrics for access requests
|
||||
async getAccessRequestMetrics(timeRange = '30days', userId = null) {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Filter requests by time range
|
||||
const now = new Date();
|
||||
let daysToLookBack = 30;
|
||||
|
||||
switch (timeRange) {
|
||||
case '7days':
|
||||
daysToLookBack = 7;
|
||||
break;
|
||||
case '30days':
|
||||
daysToLookBack = 30;
|
||||
break;
|
||||
case '90days':
|
||||
daysToLookBack = 90;
|
||||
break;
|
||||
case '365days':
|
||||
daysToLookBack = 365;
|
||||
break;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(now);
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToLookBack);
|
||||
|
||||
let requestsToAnalyze = this.accessRequests;
|
||||
|
||||
// Filter by user if specified
|
||||
if (userId) {
|
||||
requestsToAnalyze = requestsToAnalyze.filter(req => req.requesterId === userId);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
requestsToAnalyze = requestsToAnalyze.filter(req => {
|
||||
const requestDate = new Date(req.requestDate);
|
||||
return requestDate >= cutoffDate;
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const totalRequests = requestsToAnalyze.length;
|
||||
const pendingRequests = requestsToAnalyze.filter(req => req.status === 'pending').length;
|
||||
const approvedRequests = requestsToAnalyze.filter(req => req.status === 'approved').length;
|
||||
const rejectedRequests = requestsToAnalyze.filter(req => req.status === 'rejected').length;
|
||||
|
||||
// Calculate response times
|
||||
const resolvedRequests = requestsToAnalyze.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 metrics
|
||||
const overdueRequests = requestsToAnalyze.filter(req => {
|
||||
if (req.status !== 'pending') return false;
|
||||
|
||||
const deadline = new Date(req.targetResolutionTime);
|
||||
return now > deadline;
|
||||
}).length;
|
||||
|
||||
const overduePercentage = totalRequests > 0
|
||||
? overdueRequests / totalRequests
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
pendingRequests,
|
||||
approvedRequests,
|
||||
rejectedRequests,
|
||||
avgResponseTime,
|
||||
fastestResponse: fastestResponse === Infinity ? 0 : fastestResponse,
|
||||
slowestResponse,
|
||||
overdueRequests,
|
||||
overduePercentage
|
||||
};
|
||||
},
|
||||
|
||||
// Helper function to get the path for an item
|
||||
getItemPath(item) {
|
||||
if (!item.parentPath) return `/${item.name}`;
|
||||
return `${item.parentPath}/${item.name}`;
|
||||
},
|
||||
|
||||
// Find an item by ID
|
||||
findItemById(id) {
|
||||
// Look through cabinets recursively
|
||||
const findInCabinets = (cabinets, targetId) => {
|
||||
for (const cabinet of cabinets) {
|
||||
if (cabinet.id === targetId) {
|
||||
return cabinet;
|
||||
}
|
||||
|
||||
if (cabinet.children) {
|
||||
const found = findInCabinets(cabinet.children, targetId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// First check cabinets
|
||||
const cabinetItem = findInCabinets(this.cabinets, id);
|
||||
if (cabinetItem) return cabinetItem;
|
||||
|
||||
// Then check regular items
|
||||
return this.items.find(item => item.id === id);
|
||||
},
|
||||
|
||||
// Update cabinet access status
|
||||
updateCabinetAccessStatus(cabinetId, status) {
|
||||
const updateCabinet = (cabinets, id, newStatus) => {
|
||||
return cabinets.map(cabinet => {
|
||||
if (cabinet.id === id) {
|
||||
return {
|
||||
...cabinet,
|
||||
accessRequestStatus: newStatus,
|
||||
hasAccess: newStatus === 'approved',
|
||||
isLocked: newStatus === 'rejected'
|
||||
};
|
||||
}
|
||||
|
||||
if (cabinet.children) {
|
||||
return {
|
||||
...cabinet,
|
||||
children: updateCabinet(cabinet.children, id, newStatus)
|
||||
};
|
||||
}
|
||||
|
||||
return cabinet;
|
||||
});
|
||||
};
|
||||
|
||||
this.cabinets = updateCabinet(this.cabinets, cabinetId, status);
|
||||
},
|
||||
|
||||
// Authentik integration placeholder - this would be replaced with actual Authentik API calls
|
||||
async authenticateWithAuthentik(username, password) {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// This is a placeholder for the actual Authentik integration
|
||||
// In a real implementation, this would make API calls to Authentik
|
||||
|
||||
if (username === 'admin' && password === 'password') {
|
||||
return {
|
||||
user: {
|
||||
id: 'admin1',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
department: 'IT Department'
|
||||
},
|
||||
token: 'sample-authentik-token'
|
||||
};
|
||||
}
|
||||
|
||||
if (username === 'user' && password === 'password') {
|
||||
return {
|
||||
user: {
|
||||
id: 'user1',
|
||||
name: 'Aiman Fakhrullah',
|
||||
email: 'aiman@example.com',
|
||||
role: 'engineer',
|
||||
department: 'JKR Bahagian Kejuruteraan Awam Cawangan Kota Bharu'
|
||||
},
|
||||
token: 'sample-authentik-token'
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Authentication failed');
|
||||
},
|
||||
|
||||
// Get RBAC permissions from Authentik
|
||||
async getRbacPermissions(userId) {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
|
||||
// This is a placeholder for the actual Authentik integration
|
||||
// In a real implementation, this would fetch RBAC permissions from Authentik
|
||||
|
||||
const permissions = {
|
||||
'admin1': {
|
||||
roles: ['admin'],
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
approve: true,
|
||||
reject: true,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true
|
||||
},
|
||||
accessRequests: {
|
||||
approve: true,
|
||||
reject: true,
|
||||
viewAll: true
|
||||
}
|
||||
}
|
||||
},
|
||||
'user1': {
|
||||
roles: ['engineer'],
|
||||
permissions: {
|
||||
documents: {
|
||||
view: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
approve: false,
|
||||
reject: false,
|
||||
download: true
|
||||
},
|
||||
cabinets: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: false,
|
||||
reject: false,
|
||||
viewAll: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return permissions[userId] || {
|
||||
roles: [],
|
||||
permissions: {
|
||||
documents: {
|
||||
view: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
approve: false,
|
||||
reject: false,
|
||||
download: false
|
||||
},
|
||||
cabinets: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false
|
||||
},
|
||||
accessRequests: {
|
||||
approve: false,
|
||||
reject: false,
|
||||
viewAll: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user