Added Access Management and Role Management

This commit is contained in:
Aiman Fakhrullah Mantasan 2025-05-31 14:58:52 +08:00
parent b157b8b47d
commit 8cff207c45
10 changed files with 3120 additions and 259 deletions

View 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>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { useDmsStore } from '~/stores/dms'; import { useDmsStore } from '~/stores/dms';
const props = defineProps({ const props = defineProps({
@ -19,216 +19,273 @@ const emit = defineEmits(['close', 'submit']);
const dmsStore = useDmsStore(); const dmsStore = useDmsStore();
// Form state // Form state
const selectedAccessType = ref('view'); const accessType = ref('view');
const accessDuration = ref('7 days');
const justification = ref(''); const justification = ref('');
const accessDuration = ref('7 days');
const isSubmitting = ref(false); const isSubmitting = ref(false);
const formError = ref(''); const formError = ref('');
// Access type options // Durations
const accessTypes = [ const durationOptions = [
{ id: 'view', label: 'View Only', description: 'Can only view the document' }, { value: '1 day', label: '1 Day' },
{ id: 'download', label: 'Download', description: 'Can view and download' }, { value: '7 days', label: '7 Days' },
{ id: 'print', label: 'Print', description: 'Can view and print' }, { value: '14 days', label: '14 Days' },
{ id: 'full', label: 'Full Access', description: 'View, download and print' } { value: '30 days', label: '30 Days' },
{ value: '90 days', label: '90 Days' }
]; ];
// Access duration options // Access type options
const durationOptions = [ const accessTypeOptions = [
'7 days', {
'14 days', value: 'view',
'30 days', label: 'View Only',
'60 days', description: 'View documents without the ability to download or edit',
'90 days', 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>`
'Permanent' },
{
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 // Computed properties
const itemTitle = computed(() => { const itemName = computed(() => {
return props.item?.name || 'Document'; return props.item?.name || 'Document';
}); });
const itemFileName = computed(() => { const itemPath = computed(() => {
if (props.item?.type === 'file') { if (!props.item) return '';
return props.item.name; 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 // Methods
const closeDialog = () => { const resetForm = () => {
// Reset form accessType.value = 'view';
selectedAccessType.value = 'view';
accessDuration.value = '7 days';
justification.value = ''; justification.value = '';
accessDuration.value = '7 days';
formError.value = ''; formError.value = '';
emit('close');
}; };
const submitRequest = async () => { const handleClose = () => {
// Validate form emit('close');
if (!selectedAccessType.value) { // Reset form after closing
formError.value = 'Please select an access type'; setTimeout(() => {
return; resetForm();
} }, 300);
};
if (!justification.value.trim()) {
formError.value = 'Please provide a justification for your request'; const handleSubmit = () => {
if (!isValidForm.value) {
formError.value = 'Please complete all required fields';
return; return;
} }
isSubmitting.value = true; isSubmitting.value = true;
formError.value = '';
try { try {
// Submit the request to the store emit('submit', {
const request = await dmsStore.requestAccess( accessType: accessType.value,
props.item.id, justification: justification.value,
selectedAccessType.value, accessDuration: accessDuration.value
justification.value, });
accessDuration.value
);
// Emit success event
emit('submit', request);
// Close the dialog (this will also reset the form)
closeDialog();
} catch (error) { } catch (error) {
formError.value = 'Failed to submit access request. Please try again.'; console.error('Submit error:', error);
console.error('Access request error:', error); formError.value = error.message || 'Failed to submit request';
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
}; };
// Watch for visibility changes
watch(() => props.visible, (newValue) => {
if (newValue) {
resetForm();
}
});
</script> </script>
<template> <template>
<rs-modal <rs-modal
:visible="visible" v-model="props.visible"
@close="closeDialog"
title="Request Document Access"
size="md" 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> <template #body>
<div class="p-6"> <div class="p-2">
<!-- Document Information Section --> <!-- Item information -->
<div class="mb-6"> <div class="mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<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-gray-700 dark:text-gray-300 text-sm font-medium mb-2">Requesting access to:</h3>
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">Document Information</h3> <div class="flex items-start">
<div class="space-y-1"> <div class="mt-1 mr-3">
<div class="flex"> <span v-if="itemType === 'cabinet'" class="text-blue-600 dark:text-blue-400">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12">Title:</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ itemTitle }}</span> <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</div> </svg>
<div class="flex"> </span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 w-12">File:</span> <span v-else-if="itemType === 'drawer'" class="text-blue-600 dark:text-blue-400">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ itemFileName }}</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
</div> <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> </div>
</div> </div>
<!-- Form error --> <!-- 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 }} {{ formError }}
</div> </div>
<!-- Access Type Section --> <!-- Access type -->
<div class="mb-6"> <div class="mb-6">
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">Access Type</h3> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div class="grid grid-cols-2 gap-3"> Access Type
</label>
<div class="space-y-3">
<div <div
v-for="accessType in accessTypes" v-for="option in accessTypeOptions"
:key="accessType.id" :key="option.value"
class="relative" class="relative flex items-start"
> >
<label <div class="flex items-center h-5">
:for="accessType.id" <input
class="flex items-start p-3 border rounded-lg cursor-pointer transition-colors" :id="`access-type-${option.value}`"
:class="selectedAccessType === accessType.id type="radio"
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' v-model="accessType"
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'" :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 class="flex items-center h-5"> />
<input </div>
:id="accessType.id" <div class="ml-3 flex items-center">
v-model="selectedAccessType" <span v-html="option.icon" class="mr-2 text-gray-600 dark:text-gray-400"></span>
:value="accessType.id" <div>
type="radio" <label
class="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500" :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>
<div class="ml-3"> </div>
<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>
</div> </div>
<!-- Access Duration Section --> <!-- Justification -->
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Access Duration</label> <label for="justification" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<select Justification
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
<span class="text-red-500">*</span> <span class="text-red-500">*</span>
</label> </label>
<textarea <textarea
id="justification"
v-model="justification" v-model="justification"
rows="4" rows="3"
placeholder="Please explain why you need access to this document..." 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"
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" placeholder="Please explain why you need access to this resource..."
></textarea> ></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Minimum 10 characters. Please provide a detailed explanation.
</p>
</div> </div>
<!-- Footer Note --> <!-- Access Duration -->
<p class="text-xs text-gray-500 dark:text-gray-400 mb-6"> <div class="mb-6">
Your request will be reviewed by the document owner or administrator. <label for="duration" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</p> 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> </div>
</template> </template>
<template #footer> <template #footer>
<div class="flex justify-end gap-3 px-6 py-4 bg-gray-50 dark:bg-gray-800"> <div class="flex justify-between items-center">
<rs-button <div>
@click="closeDialog" <p class="text-xs text-gray-500 dark:text-gray-400">
:disabled="isSubmitting" * Required fields
variant="secondary-outline" </p>
size="sm" </div>
> <div class="flex space-x-2">
Cancel <rs-button
</rs-button> variant="light"
<rs-button @click="handleClose"
@click="submitRequest" :disabled="isSubmitting"
:disabled="isSubmitting" >
variant="primary" Cancel
size="sm" </rs-button>
> <rs-button
<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"> variant="primary"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> @click="handleSubmit"
<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> :disabled="!isValidForm || isSubmitting"
</svg> :loading="isSubmitting"
Submit Request >
</rs-button> Submit Request
</rs-button>
</div>
</div> </div>
</template> </template>
</rs-modal> </rs-modal>

View File

@ -5,6 +5,7 @@ import DMSDocumentViewer from '~/components/dms/viewers/DMSDocumentViewer.vue';
import DMSUploadDialog from '~/components/dms/dialogs/DMSUploadDialog.vue'; import DMSUploadDialog from '~/components/dms/dialogs/DMSUploadDialog.vue';
import DMSCreateNewDialog from '~/components/dms/dialogs/DMSCreateNewDialog.vue'; import DMSCreateNewDialog from '~/components/dms/dialogs/DMSCreateNewDialog.vue';
import DMSAccessRequestDialog from '~/components/dms/dialogs/DMSAccessRequestDialog.vue'; import DMSAccessRequestDialog from '~/components/dms/dialogs/DMSAccessRequestDialog.vue';
import { useDmsStore } from '~/stores/dms';
// Props for the explorer // Props for the explorer
const props = defineProps({ const props = defineProps({
@ -36,6 +37,9 @@ const sortOrder = ref('asc'); // asc, desc
const searchQuery = ref(''); const searchQuery = ref('');
const showAccessRequestDialog = ref(false); const showAccessRequestDialog = ref(false);
const accessRequestItem = ref(null); const accessRequestItem = ref(null);
const isRequestingAccess = ref(false);
const requestSuccess = ref(false);
const requestError = ref(null);
// Navigation history for back/forward functionality // Navigation history for back/forward functionality
const navigationHistory = ref([props.initialPath]); const navigationHistory = ref([props.initialPath]);
@ -52,6 +56,7 @@ const currentDocument = ref(null);
// Mock data - replace with actual API calls // Mock data - replace with actual API calls
const allItems = ref([]); const allItems = ref([]);
const router = useRouter(); const router = useRouter();
const dmsStore = useDmsStore();
// Methods // Methods
const loadItems = async () => { const loadItems = async () => {
@ -600,6 +605,39 @@ const closeAccessRequestDialog = () => {
accessRequestItem.value = null; 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 // Computed properties
const currentItems = computed(() => { const currentItems = computed(() => {
let items = allItems.value.filter(item => item.parentPath === currentPath.value); 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)" <span v-if="item.accessType === 'private' && !checkItemAccess(item)"
v-html="getSvgIcon('lock', 16)" 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> 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>
</div> </div>
@ -1345,21 +1410,60 @@ const parentType = computed(() => {
/> />
<!-- Create New Dialog --> <!-- Create New Dialog -->
<DMSCreateNewDialog <DMSCreateNewDialog
v-model:visible="showCreateDialog" v-if="showCreateDialog"
:current-path="currentPath"
:parent-type="parentType"
@create="handleCreateNew"
@close="showCreateDialog = false" @close="showCreateDialog = false"
@create="handleCreateNew"
/> />
<!-- Access Request Dialog --> <!-- Access Request Dialog -->
<DMSAccessRequestDialog <DMSAccessRequestDialog
:visible="showAccessRequestDialog" v-if="accessRequestItem"
:item="accessRequestItem" :item="accessRequestItem"
@submit="handleAccessRequestSubmitted" :visible="showAccessRequestDialog"
@close="closeAccessRequestDialog" @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> </div>
</template> </template>

View 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>

View 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>

View File

@ -10,12 +10,6 @@ export default [
"child": [], "child": [],
"meta": {} "meta": {}
}, },
{
"title": "Access Requests",
"path": "/dms/access-requests",
"icon": "ic:outline-security",
"child": []
},
{ {
"title": "Settings", "title": "Settings",
"path": "/dms/settings", "path": "/dms/settings",

View 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>

View File

@ -181,6 +181,41 @@ onMounted(() => {
<!-- Content Area --> <!-- Content Area -->
<div class="content-area flex-1"> <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 --> <!-- DMS Explorer Component -->
<component <component
v-if="componentsLoaded && DMSExplorer" v-if="componentsLoaded && DMSExplorer"

View 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>

View File

@ -30,6 +30,224 @@ export const useDmsStore = defineStore('dms', {
{ id: 'rejected', name: 'Rejected', color: 'red' } { 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 // Items state - in production this would be loaded from API
cabinets: [ cabinets: [
{ {
@ -268,75 +486,24 @@ export const useDmsStore = defineStore('dms', {
} }
], ],
// Access requests // Search state
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: '',
searchResults: [], 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: { systemSettings: {
upload: { upload: {
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png'], allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png'],
@ -521,8 +688,44 @@ export const useDmsStore = defineStore('dms', {
}, },
// Get pending access requests // Get pending access requests
pendingAccessRequests: (state) => { pendingAccessRequests() {
return state.accessRequests.filter(req => req.status === 'pending'); 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 // Access request functionality
async requestAccess(itemId, accessLevel, justification, duration = '7 days') { async requestAccess(itemId, accessType, justification, duration = '7 days') {
this.isLoading = true; this.isLoading = true;
try { try {
// Mock API delay // Mock API delay
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 800));
// Generate a unique request ID // Find the item being requested
const requestId = `req${Date.now()}`; 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 // Create new access request
const newRequest = { const newRequest = {
id: requestId, id: `req-${Date.now()}`,
userId: 'current-user-id', // Would come from auth store documentId: item.id,
userName: 'Current User', // Would come from auth store documentName: item.name,
itemId: itemId, documentPath: this.getItemPath(item),
accessLevel: accessLevel, requesterId: this.currentUser.id,
justification: justification, requesterName: this.currentUser.name,
duration: duration, requesterEmail: this.currentUser.email,
requestDate: new Date().toISOString().split('T')[0], requesterDepartment: this.currentUser.department,
status: 'pending' 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); this.accessRequests.push(newRequest);
// Update the item's access request status (for mock data) // Update the item's access request status if it's a cabinet
// In production, this would be handled server-side if (item.type === 'cabinet' || item.type === 'cabinet-group') {
const updateItemStatus = (items, id) => { this.updateCabinetAccessStatus(itemId, 'pending');
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
return newRequest; return newRequest;
} catch (error) { } catch (error) {
@ -1199,6 +1411,374 @@ export const useDmsStore = defineStore('dms', {
} finally { } finally {
this.isLoading = false; 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
}
}
};
} }
} }
}); });