generated from corrad-software/corrad-af-2024
510 lines
15 KiB
Vue
510 lines
15 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted } from 'vue';
|
|
import { useDmsStore } from '~/stores/dms';
|
|
|
|
const props = defineProps({
|
|
currentPath: {
|
|
type: String,
|
|
default: '/'
|
|
},
|
|
allowMultiple: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
maxFileSize: {
|
|
type: Number,
|
|
default: 100 * 1024 * 1024 // 100MB
|
|
},
|
|
allowedTypes: {
|
|
type: Array,
|
|
default: () => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png']
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['upload-complete', 'upload-error', 'upload-progress']);
|
|
|
|
// Store
|
|
const dmsStore = useDmsStore();
|
|
|
|
// Upload state
|
|
const uploadQueue = ref([]);
|
|
const isUploading = ref(false);
|
|
const dragCounter = ref(0);
|
|
const isDragOver = ref(false);
|
|
const fileInput = ref(null);
|
|
|
|
// Upload progress tracking
|
|
const uploadProgress = ref({});
|
|
const overallProgress = computed(() => {
|
|
const files = Object.keys(uploadProgress.value);
|
|
if (files.length === 0) return 0;
|
|
|
|
const total = files.reduce((sum, fileId) => sum + uploadProgress.value[fileId], 0);
|
|
return Math.round(total / files.length);
|
|
});
|
|
|
|
// File validation
|
|
const validateFile = (file) => {
|
|
const errors = [];
|
|
|
|
// Check file size
|
|
if (file.size > props.maxFileSize) {
|
|
errors.push(`File size exceeds limit of ${formatFileSize(props.maxFileSize)}`);
|
|
}
|
|
|
|
// Check file type
|
|
const extension = file.name.split('.').pop().toLowerCase();
|
|
if (!props.allowedTypes.includes(extension)) {
|
|
errors.push(`File type .${extension} is not allowed`);
|
|
}
|
|
|
|
// Check user quota
|
|
if (dmsStore.userQuotas.used + file.size > dmsStore.userQuotas.limit) {
|
|
errors.push('Upload would exceed user storage quota');
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors
|
|
};
|
|
};
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
// Generate unique file ID
|
|
const generateFileId = () => {
|
|
return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
};
|
|
|
|
// Add files to upload queue
|
|
const addFilesToQueue = (files) => {
|
|
const newFiles = [];
|
|
|
|
for (const file of files) {
|
|
const validation = validateFile(file);
|
|
const fileId = generateFileId();
|
|
|
|
const uploadFile = {
|
|
id: fileId,
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
extension: file.name.split('.').pop().toLowerCase(),
|
|
status: validation.isValid ? 'pending' : 'error',
|
|
errors: validation.errors,
|
|
progress: 0,
|
|
metadata: {
|
|
title: file.name.split('.')[0],
|
|
description: '',
|
|
department: dmsStore.currentUser.department || '',
|
|
author: dmsStore.currentUser.name || '',
|
|
tags: [],
|
|
priority: 'Medium',
|
|
projectCode: '',
|
|
accessType: 'private'
|
|
}
|
|
};
|
|
|
|
newFiles.push(uploadFile);
|
|
uploadProgress.value[fileId] = 0;
|
|
}
|
|
|
|
uploadQueue.value = [...uploadQueue.value, ...newFiles];
|
|
return newFiles;
|
|
};
|
|
|
|
// Remove file from queue
|
|
const removeFromQueue = (fileId) => {
|
|
const index = uploadQueue.value.findIndex(f => f.id === fileId);
|
|
if (index > -1) {
|
|
uploadQueue.value.splice(index, 1);
|
|
delete uploadProgress.value[fileId];
|
|
}
|
|
};
|
|
|
|
// Clear completed uploads
|
|
const clearCompleted = () => {
|
|
uploadQueue.value = uploadQueue.value.filter(f =>
|
|
f.status !== 'completed' && f.status !== 'error'
|
|
);
|
|
};
|
|
|
|
// Upload single file
|
|
const uploadSingleFile = async (uploadFile) => {
|
|
try {
|
|
uploadFile.status = 'uploading';
|
|
|
|
// Simulate chunked upload with progress
|
|
const chunkSize = 1024 * 1024; // 1MB chunks
|
|
const totalChunks = Math.ceil(uploadFile.file.size / chunkSize);
|
|
|
|
for (let chunk = 0; chunk < totalChunks; chunk++) {
|
|
// Simulate chunk upload delay
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
const progress = Math.round(((chunk + 1) / totalChunks) * 100);
|
|
uploadProgress.value[uploadFile.id] = progress;
|
|
uploadFile.progress = progress;
|
|
|
|
emit('upload-progress', {
|
|
fileId: uploadFile.id,
|
|
progress,
|
|
overall: overallProgress.value
|
|
});
|
|
}
|
|
|
|
// Create document in store
|
|
const document = await dmsStore.uploadFileWithMetadata(
|
|
uploadFile.file,
|
|
uploadFile.metadata,
|
|
props.currentPath
|
|
);
|
|
|
|
uploadFile.status = 'completed';
|
|
uploadFile.documentId = document.id;
|
|
|
|
return document;
|
|
|
|
} catch (error) {
|
|
uploadFile.status = 'error';
|
|
uploadFile.errors = [error.message];
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Upload all files in queue
|
|
const uploadAllFiles = async () => {
|
|
if (isUploading.value) return;
|
|
|
|
isUploading.value = true;
|
|
const pendingFiles = uploadQueue.value.filter(f => f.status === 'pending');
|
|
|
|
try {
|
|
const uploadPromises = pendingFiles.map(uploadSingleFile);
|
|
const results = await Promise.allSettled(uploadPromises);
|
|
|
|
const completed = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
|
const failed = results.filter(r => r.status === 'rejected');
|
|
|
|
emit('upload-complete', {
|
|
completed,
|
|
failed: failed.length,
|
|
total: pendingFiles.length
|
|
});
|
|
|
|
if (failed.length > 0) {
|
|
emit('upload-error', {
|
|
message: `${failed.length} file(s) failed to upload`,
|
|
failures: failed
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
emit('upload-error', { message: error.message });
|
|
} finally {
|
|
isUploading.value = false;
|
|
}
|
|
};
|
|
|
|
// Drag and drop handlers
|
|
const handleDragEnter = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.value++;
|
|
isDragOver.value = true;
|
|
};
|
|
|
|
const handleDragLeave = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.value--;
|
|
if (dragCounter.value === 0) {
|
|
isDragOver.value = false;
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault();
|
|
dragCounter.value = 0;
|
|
isDragOver.value = false;
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length > 0) {
|
|
addFilesToQueue(files);
|
|
}
|
|
};
|
|
|
|
// File input handler
|
|
const handleFileInput = (e) => {
|
|
const files = Array.from(e.target.files);
|
|
if (files.length > 0) {
|
|
addFilesToQueue(files);
|
|
}
|
|
|
|
// Reset input
|
|
if (fileInput.value) {
|
|
fileInput.value.value = '';
|
|
}
|
|
};
|
|
|
|
// Open file dialog
|
|
const openFileDialog = () => {
|
|
if (fileInput.value) {
|
|
fileInput.value.click();
|
|
}
|
|
};
|
|
|
|
// Update metadata for a file
|
|
const updateFileMetadata = (fileId, metadata) => {
|
|
const file = uploadQueue.value.find(f => f.id === fileId);
|
|
if (file) {
|
|
file.metadata = { ...file.metadata, ...metadata };
|
|
}
|
|
};
|
|
|
|
// Get status color
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'pending': return 'blue';
|
|
case 'uploading': return 'yellow';
|
|
case 'completed': return 'green';
|
|
case 'error': return 'red';
|
|
default: return 'gray';
|
|
}
|
|
};
|
|
|
|
// Get status icon
|
|
const getStatusIcon = (status) => {
|
|
switch (status) {
|
|
case 'pending': return 'mdi:clock-outline';
|
|
case 'uploading': return 'mdi:upload';
|
|
case 'completed': return 'mdi:check-circle';
|
|
case 'error': return 'mdi:alert-circle';
|
|
default: return 'mdi:file';
|
|
}
|
|
};
|
|
|
|
// Watch for auto-upload
|
|
watch(() => uploadQueue.value.length, (newLength, oldLength) => {
|
|
if (newLength > oldLength && !isUploading.value) {
|
|
// Auto-start upload if there are pending files
|
|
const pendingFiles = uploadQueue.value.filter(f => f.status === 'pending');
|
|
if (pendingFiles.length > 0) {
|
|
uploadAllFiles();
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="file-upload-manager">
|
|
<!-- Hidden file input -->
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="hidden"
|
|
:multiple="allowMultiple"
|
|
:accept="allowedTypes.map(ext => `.${ext}`).join(',')"
|
|
@change="handleFileInput"
|
|
/>
|
|
|
|
<!-- Drop zone -->
|
|
<div
|
|
class="drop-zone border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
|
:class="{
|
|
'border-blue-500 bg-blue-50 dark:bg-blue-900/10': isDragOver,
|
|
'border-gray-300 dark:border-gray-600': !isDragOver,
|
|
'hover:border-gray-400 dark:hover:border-gray-500': !isDragOver
|
|
}"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="handleDragLeave"
|
|
@dragover="handleDragOver"
|
|
@drop="handleDrop"
|
|
>
|
|
<div class="space-y-4">
|
|
<Icon
|
|
name="mdi:cloud-upload"
|
|
class="w-16 h-16 mx-auto text-gray-400"
|
|
:class="{ 'text-blue-500': isDragOver }"
|
|
/>
|
|
|
|
<div>
|
|
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
Drop files here to upload
|
|
</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
or
|
|
<button
|
|
@click="openFileDialog"
|
|
class="text-blue-600 hover:text-blue-700 underline"
|
|
>
|
|
browse to choose files
|
|
</button>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="text-xs text-gray-400 dark:text-gray-500">
|
|
<p>Supported formats: {{ allowedTypes.join(', ').toUpperCase() }}</p>
|
|
<p>Maximum file size: {{ formatFileSize(maxFileSize) }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload queue -->
|
|
<div v-if="uploadQueue.length > 0" class="mt-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
Upload Queue ({{ uploadQueue.length }})
|
|
</h3>
|
|
|
|
<div class="flex space-x-2">
|
|
<button
|
|
v-if="!isUploading"
|
|
@click="uploadAllFiles"
|
|
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
|
|
:disabled="uploadQueue.filter(f => f.status === 'pending').length === 0"
|
|
>
|
|
Upload All
|
|
</button>
|
|
|
|
<button
|
|
@click="clearCompleted"
|
|
class="px-3 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm"
|
|
>
|
|
Clear Completed
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Overall progress -->
|
|
<div v-if="isUploading" class="mb-4">
|
|
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
|
<span>Overall Progress</span>
|
|
<span>{{ overallProgress }}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
:style="{ width: `${overallProgress}%` }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File list -->
|
|
<div class="space-y-3 max-h-96 overflow-y-auto">
|
|
<div
|
|
v-for="uploadFile in uploadQueue"
|
|
:key="uploadFile.id"
|
|
class="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
|
|
>
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-center space-x-3 flex-1">
|
|
<Icon
|
|
:name="getStatusIcon(uploadFile.status)"
|
|
class="w-5 h-5 flex-shrink-0"
|
|
:class="`text-${getStatusColor(uploadFile.status)}-500`"
|
|
/>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
{{ uploadFile.name }}
|
|
</h4>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{{ formatFileSize(uploadFile.size) }} • {{ uploadFile.extension.toUpperCase() }}
|
|
</p>
|
|
|
|
<!-- Progress bar -->
|
|
<div v-if="uploadFile.status === 'uploading'" class="mt-2">
|
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1">
|
|
<div
|
|
class="bg-blue-600 h-1 rounded-full transition-all duration-300"
|
|
:style="{ width: `${uploadFile.progress}%` }"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Errors -->
|
|
<div v-if="uploadFile.errors.length > 0" class="mt-2">
|
|
<p class="text-xs text-red-600 dark:text-red-400">
|
|
{{ uploadFile.errors.join(', ') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
v-if="uploadFile.status !== 'uploading'"
|
|
@click="removeFromQueue(uploadFile.id)"
|
|
class="ml-2 p-1 text-gray-400 hover:text-red-600"
|
|
>
|
|
<Icon name="mdi:close" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Metadata form for pending files -->
|
|
<div v-if="uploadFile.status === 'pending'" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Title
|
|
</label>
|
|
<input
|
|
v-model="uploadFile.metadata.title"
|
|
type="text"
|
|
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Department
|
|
</label>
|
|
<select
|
|
v-model="uploadFile.metadata.department"
|
|
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
|
>
|
|
<option value="">Select Department</option>
|
|
<option value="HR">HR Department</option>
|
|
<option value="Finance">Finance</option>
|
|
<option value="IT">IT</option>
|
|
<option value="Legal">Legal</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
v-model="uploadFile.metadata.description"
|
|
rows="2"
|
|
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
|
|
placeholder="Brief description of the document..."
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.drop-zone {
|
|
min-height: 200px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
</style> |