feat: user certificate and document upload galleries
Add FilePond temp upload with form commit, PhotoSwipe zoom/swipe galleries, and user detail route for files.
This commit is contained in:
@@ -5,12 +5,15 @@ export const certificateApi = {
|
|||||||
getAll: (params) => axiosInstance.get('/certificates/admin/get-all', { params }),
|
getAll: (params) => axiosInstance.get('/certificates/admin/get-all', { params }),
|
||||||
search: (params) => axiosInstance.get('/certificates/admin/search', { params }),
|
search: (params) => axiosInstance.get('/certificates/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/certificates/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/certificates/admin/get-one/${id}`),
|
||||||
|
getByUser: (userId) => axiosInstance.get(`/certificates/admin/by-user/${userId}`),
|
||||||
create: (data) => axiosInstance.post('/certificates/admin/create', data),
|
create: (data) => axiosInstance.post('/certificates/admin/create', data),
|
||||||
update: (id, data) => axiosInstance.put(`/certificates/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/certificates/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/certificates/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/certificates/admin/delete/${id}`),
|
||||||
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
|
uploadTemp: (formData) => axiosInstance.post('/files/admin/upload-temp', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
}),
|
}),
|
||||||
|
getSignedUrl: (filename, bucket = 'temp') =>
|
||||||
|
axiosInstance.get(`/files/admin/signed-url/${encodeURIComponent(filename)}`, { params: { bucket } }),
|
||||||
userUpload: (data) => axiosInstance.post('/certificates/user/upload', data),
|
userUpload: (data) => axiosInstance.post('/certificates/user/upload', data),
|
||||||
getUserCertificates: () => axiosInstance.get('/certificates/user/my-certificates')
|
getUserCertificates: () => axiosInstance.get('/certificates/user/my-certificates')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// /src/api/documentApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const documentApi = {
|
||||||
|
getAll: (params) => axiosInstance.get('/documents/admin/get-all', { params }),
|
||||||
|
getByUser: (userId) => axiosInstance.get(`/documents/admin/by-user/${userId}`),
|
||||||
|
getOne: (id) => axiosInstance.get(`/documents/admin/get-one/${id}`),
|
||||||
|
create: (data) => axiosInstance.post('/documents/admin/create', data),
|
||||||
|
update: (id, data) => axiosInstance.put(`/documents/admin/update/${id}`, data),
|
||||||
|
delete: (id) => axiosInstance.delete(`/documents/admin/delete/${id}`),
|
||||||
|
getMyDocuments: () => axiosInstance.get('/documents/user/my-documents')
|
||||||
|
};
|
||||||
@@ -1,37 +1,20 @@
|
|||||||
<!-- /src/components/uploader/CertificateUploader.vue -->
|
<!-- /src/components/uploader/CertificateUploader.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="certificate-uploader">
|
<UserFilesSection :user-id="userId" />
|
||||||
<ImageManager
|
|
||||||
v-model="certificates"
|
|
||||||
upload-endpoint="/certificates/user/upload"
|
|
||||||
delete-endpoint="/files/admin/delete"
|
|
||||||
:max-files="1"
|
|
||||||
:allow-multiple="false"
|
|
||||||
gallery-id="certificate-gallery"
|
|
||||||
@upload-success="onCertificateUploaded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import UserFilesSection from './UserFilesSection.vue';
|
||||||
import ImageManager from './ImageManager.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
|
userId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
// kept for backward compatibility with older callers
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'uploaded']);
|
|
||||||
|
|
||||||
const certificates = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val)
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCertificateUploaded = (fileData) => {
|
|
||||||
emit('uploaded', fileData);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,55 +1,71 @@
|
|||||||
<!-- /src/components/uploader/ImageManager.vue -->
|
<!-- /src/components/uploader/ImageManager.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="image-manager">
|
<div class="image-manager">
|
||||||
<!-- FilePond Upload Input -->
|
|
||||||
<file-pond
|
<file-pond
|
||||||
|
v-if="allowUpload"
|
||||||
ref="pond"
|
ref="pond"
|
||||||
name="file"
|
name="file"
|
||||||
:label-idle="labelIdle"
|
:label-idle="labelIdle"
|
||||||
:allow-multiple="allowMultiple"
|
:allow-multiple="allowMultiple"
|
||||||
:max-files="maxFiles"
|
:max-files="maxFiles"
|
||||||
accepted-file-types="image/jpeg, image/png, image/webp, application/pdf"
|
:accepted-file-types="acceptedFileTypes"
|
||||||
:server="serverOptions"
|
:server="serverOptions"
|
||||||
@processfile="handleProcessFile"
|
@processfile="handleProcessFile"
|
||||||
@removefile="handleRemoveFile"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Gallery Lightbox Preview Grid -->
|
<div
|
||||||
<div v-if="imagesList && imagesList.length > 0" class="gallery-grid grid mt-3" :id="galleryId">
|
v-if="imagesList && imagesList.length > 0"
|
||||||
|
class="gallery-grid grid mt-3"
|
||||||
|
:id="galleryId"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(img, index) in imagesList"
|
v-for="(img, index) in imagesList"
|
||||||
:key="img.url || index"
|
:key="img.id || img.tempFileName || img.url || index"
|
||||||
class="col-12 sm:col-6 md:col-4 lg:col-3"
|
class="col-12 sm:col-6 md:col-4 lg:col-3"
|
||||||
>
|
>
|
||||||
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative group">
|
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative">
|
||||||
<a
|
<a
|
||||||
:href="img.url"
|
:href="img.url || img.signedUrl || img.presignedUrl"
|
||||||
:data-pswp-width="img.width || 1200"
|
:data-pswp-width="img.width || 1600"
|
||||||
:data-pswp-height="img.height || 800"
|
:data-pswp-height="img.height || 1200"
|
||||||
|
:data-pswp-type="isPdf(img) ? 'iframe' : undefined"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="block overflow-hidden"
|
rel="noopener"
|
||||||
|
class="block overflow-hidden gallery-thumb"
|
||||||
>
|
>
|
||||||
<img :src="img.url" :alt="img.name || 'تصویر'" class="w-full h-10rem object-cover block transition-transform transition-duration-200 hover:scale-105" />
|
<img
|
||||||
|
v-if="!isPdf(img)"
|
||||||
|
:src="img.url || img.signedUrl || img.presignedUrl"
|
||||||
|
:alt="img.name || img.fileName || img.title || 'تصویر'"
|
||||||
|
class="w-full h-10rem object-cover block"
|
||||||
|
/>
|
||||||
|
<div v-else class="pdf-thumb flex flex-column align-items-center justify-content-center h-10rem gap-2">
|
||||||
|
<i class="pi pi-file-pdf text-3xl text-red-500"></i>
|
||||||
|
<span class="text-xs text-muted">PDF</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="p-2 flex align-items-center justify-content-between border-top-1 border-color">
|
<div class="p-2 flex align-items-center justify-content-between border-top-1 border-color gap-2">
|
||||||
<span class="text-xs text-muted truncate max-w-8rem">{{ img.name || `فایل ${index + 1}` }}</span>
|
<span class="text-xs text-muted truncate">{{ img.name || img.fileName || img.title || `فایل ${index + 1}` }}</span>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="allowDelete"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
severity="danger"
|
severity="danger"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
@click="removeGalleryImage(index)"
|
@click.stop="removeGalleryImage(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="!allowUpload" class="text-muted text-sm m-0 mt-2">فایلی ثبت نشده است</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||||
import vueFilePond from 'vue-filepond';
|
import vueFilePond from 'vue-filepond';
|
||||||
import 'filepond/dist/filepond.min.css';
|
import 'filepond/dist/filepond.min.css';
|
||||||
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
|
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
|
||||||
@@ -59,6 +75,7 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
|||||||
import 'photoswipe/style.css';
|
import 'photoswipe/style.css';
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import axiosInstance from '@/api/axiosInstance';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
|
|
||||||
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
|
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
|
||||||
@@ -72,25 +89,37 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '/files/admin/upload-temp'
|
default: '/files/admin/upload-temp'
|
||||||
},
|
},
|
||||||
deleteEndpoint: {
|
tempBucket: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '/files/admin/delete'
|
default: 'temp'
|
||||||
},
|
},
|
||||||
maxFiles: {
|
maxFiles: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 5
|
default: 10
|
||||||
},
|
},
|
||||||
allowMultiple: {
|
allowMultiple: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
allowUpload: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
allowDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
galleryId: {
|
galleryId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'pswp-gallery'
|
default: 'pswp-gallery'
|
||||||
|
},
|
||||||
|
acceptedFileTypes: {
|
||||||
|
type: String,
|
||||||
|
default: 'image/jpeg, image/png, image/webp, image/gif, application/pdf'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'upload-success']);
|
const emit = defineEmits(['update:modelValue', 'upload-success', 'remove']);
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const pond = ref(null);
|
const pond = ref(null);
|
||||||
@@ -99,12 +128,17 @@ let lightbox = null;
|
|||||||
const labelIdle = 'کشیدن و رها کردن فایل یا <span class="filepond--label-action">مرور سیستم</span>';
|
const labelIdle = 'کشیدن و رها کردن فایل یا <span class="filepond--label-action">مرور سیستم</span>';
|
||||||
|
|
||||||
const imagesList = computed({
|
const imagesList = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue || [],
|
||||||
set: (val) => emit('update:modelValue', val)
|
set: (val) => emit('update:modelValue', val)
|
||||||
});
|
});
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
const isPdf = (img) => {
|
||||||
|
const name = `${img?.name || ''} ${img?.fileName || ''} ${img?.mimeType || ''} ${img?.url || ''}`.toLowerCase();
|
||||||
|
return name.includes('pdf') || name.includes('application/pdf');
|
||||||
|
};
|
||||||
|
|
||||||
const serverOptions = computed(() => ({
|
const serverOptions = computed(() => ({
|
||||||
url: API_BASE_URL,
|
url: API_BASE_URL,
|
||||||
process: {
|
process: {
|
||||||
@@ -116,72 +150,117 @@ const serverOptions = computed(() => ({
|
|||||||
try {
|
try {
|
||||||
const res = typeof response === 'string' ? JSON.parse(response) : response;
|
const res = typeof response === 'string' ? JSON.parse(response) : response;
|
||||||
const fileData = res.data || res;
|
const fileData = res.data || res;
|
||||||
return fileData.tempFileName || fileData.filename || fileData.url || JSON.stringify(fileData);
|
return JSON.stringify({
|
||||||
|
tempFileName: fileData.tempFileName || fileData.filename,
|
||||||
|
originalName: fileData.originalName,
|
||||||
|
mimeType: fileData.mimeType
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
revert: {
|
revert: null
|
||||||
url: props.deleteEndpoint,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${authStore.accessToken}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleProcessFile = (error, file) => {
|
const fetchTempSignedUrl = async (tempFileName) => {
|
||||||
if (!error) {
|
try {
|
||||||
let serverRes = file.serverId;
|
const res = await axiosInstance.get(
|
||||||
let url = '';
|
`/files/admin/signed-url/${encodeURIComponent(tempFileName)}`,
|
||||||
let tempFileName = '';
|
{ params: { bucket: props.tempBucket } }
|
||||||
try {
|
);
|
||||||
const parsed = JSON.parse(serverRes);
|
const data = res.data || res;
|
||||||
url = parsed.url || parsed.publicUrl || '';
|
return data.presignedUrl || data.signedUrl || data.url || '';
|
||||||
tempFileName = parsed.tempFileName || parsed.filename || '';
|
} catch {
|
||||||
} catch (e) {
|
return '';
|
||||||
tempFileName = serverRes;
|
|
||||||
url = `${API_BASE_URL}/files/admin/signed-url/${serverRes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newItem = {
|
|
||||||
name: file.filename,
|
|
||||||
tempFileName: tempFileName || file.filename,
|
|
||||||
url: url || `${API_BASE_URL}/files/admin/signed-url/${tempFileName}`,
|
|
||||||
width: 1200,
|
|
||||||
height: 800
|
|
||||||
};
|
|
||||||
|
|
||||||
const updated = [...imagesList.value, newItem];
|
|
||||||
emit('update:modelValue', updated);
|
|
||||||
emit('upload-success', newItem);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFile = (error, file) => {
|
const handleProcessFile = async (error, file) => {
|
||||||
// handled via gallery card actions or filepond
|
if (error) return;
|
||||||
|
|
||||||
|
let tempFileName = '';
|
||||||
|
let originalName = file.filename;
|
||||||
|
let mimeType = file.fileType || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(file.serverId);
|
||||||
|
tempFileName = parsed.tempFileName || '';
|
||||||
|
originalName = parsed.originalName || originalName;
|
||||||
|
mimeType = parsed.mimeType || mimeType;
|
||||||
|
} catch {
|
||||||
|
tempFileName = file.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = await fetchTempSignedUrl(tempFileName);
|
||||||
|
|
||||||
|
const newItem = {
|
||||||
|
name: originalName,
|
||||||
|
fileName: originalName,
|
||||||
|
tempFileName,
|
||||||
|
mimeType,
|
||||||
|
pending: true,
|
||||||
|
url: signedUrl,
|
||||||
|
signedUrl,
|
||||||
|
width: 1600,
|
||||||
|
height: 1200
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = [...imagesList.value, newItem];
|
||||||
|
emit('update:modelValue', updated);
|
||||||
|
emit('upload-success', newItem);
|
||||||
|
await nextTick();
|
||||||
|
reinitLightbox();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeGalleryImage = (index) => {
|
const removeGalleryImage = (index) => {
|
||||||
|
const item = imagesList.value[index];
|
||||||
const updated = [...imagesList.value];
|
const updated = [...imagesList.value];
|
||||||
updated.splice(index, 1);
|
updated.splice(index, 1);
|
||||||
emit('update:modelValue', updated);
|
emit('update:modelValue', updated);
|
||||||
|
emit('remove', item);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const destroyLightbox = () => {
|
||||||
lightbox = new PhotoSwipeLightbox({
|
|
||||||
gallery: `#${props.galleryId}`,
|
|
||||||
children: 'a',
|
|
||||||
pswpModule: () => import('photoswipe')
|
|
||||||
});
|
|
||||||
lightbox.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (lightbox) {
|
if (lightbox) {
|
||||||
lightbox.destroy();
|
lightbox.destroy();
|
||||||
lightbox = null;
|
lightbox = null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reinitLightbox = () => {
|
||||||
|
destroyLightbox();
|
||||||
|
const el = document.getElementById(props.galleryId);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
lightbox = new PhotoSwipeLightbox({
|
||||||
|
gallery: `#${props.galleryId}`,
|
||||||
|
children: 'a',
|
||||||
|
pswpModule: () => import('photoswipe'),
|
||||||
|
// Enable zoom + swipe (PhotoSwipe defaults)
|
||||||
|
wheelToZoom: true,
|
||||||
|
initialZoomLevel: 'fit',
|
||||||
|
secondaryZoomLevel: 2.5,
|
||||||
|
maxZoomLevel: 4
|
||||||
|
});
|
||||||
|
lightbox.init();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue?.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
reinitLightbox();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
reinitLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyLightbox();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -191,8 +270,17 @@ onUnmounted(() => {
|
|||||||
background-color: var(--surface-card);
|
background-color: var(--surface-card);
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filepond--drop-label {
|
.filepond--drop-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-thumb {
|
||||||
|
background: var(--surface-ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumb {
|
||||||
|
background: var(--surface-ground);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<!-- Upload certificates/documents: FilePond → temp, then commit on submit -->
|
||||||
|
<template>
|
||||||
|
<div class="user-files-section flex flex-column gap-4">
|
||||||
|
<div class="surface-card p-3 sm:p-4 border-round border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold m-0">گواهینامهها</h3>
|
||||||
|
<p class="text-muted text-sm m-0 mt-1">آپلود به باکت موقت، سپس ثبت نهایی در باکت certificates</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="pendingCertificates.length"
|
||||||
|
type="button"
|
||||||
|
label="ثبت گواهینامهها"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
:loading="savingCertificates"
|
||||||
|
@click="commitCertificates"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageManager
|
||||||
|
v-model="certificateItems"
|
||||||
|
gallery-id="user-certificates-gallery"
|
||||||
|
:max-files="20"
|
||||||
|
:allow-multiple="true"
|
||||||
|
upload-endpoint="/files/admin/upload-temp"
|
||||||
|
temp-bucket="temp"
|
||||||
|
@remove="onCertificateRemove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-card p-3 sm:p-4 border-round border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold m-0">اسناد و مدارک</h3>
|
||||||
|
<p class="text-muted text-sm m-0 mt-1">آپلود به باکت موقت، سپس ثبت نهایی در باکت documents (خصوصی)</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="pendingDocuments.length"
|
||||||
|
type="button"
|
||||||
|
label="ثبت اسناد"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
severity="help"
|
||||||
|
:loading="savingDocuments"
|
||||||
|
@click="commitDocuments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" v-if="pendingDocuments.length">
|
||||||
|
<label class="font-semibold text-sm block mb-2">عنوان سند (اختیاری برای همه فایلهای در انتظار)</label>
|
||||||
|
<InputText v-model="documentTitle" class="w-full text-sm" placeholder="مثلاً کارت ملی / شناسنامه" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageManager
|
||||||
|
v-model="documentItems"
|
||||||
|
gallery-id="user-documents-gallery"
|
||||||
|
:max-files="20"
|
||||||
|
:allow-multiple="true"
|
||||||
|
upload-endpoint="/files/admin/upload-temp"
|
||||||
|
temp-bucket="temp"
|
||||||
|
@remove="onDocumentRemove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import ImageManager from './ImageManager.vue';
|
||||||
|
import { certificateApi } from '@/api/certificateApi';
|
||||||
|
import { documentApi } from '@/api/documentApi';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
userId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const certificateItems = ref([]);
|
||||||
|
const documentItems = ref([]);
|
||||||
|
const documentTitle = ref('');
|
||||||
|
const savingCertificates = ref(false);
|
||||||
|
const savingDocuments = ref(false);
|
||||||
|
|
||||||
|
const pendingCertificates = computed(() => certificateItems.value.filter((i) => i.pending && i.tempFileName));
|
||||||
|
const pendingDocuments = computed(() => documentItems.value.filter((i) => i.pending && i.tempFileName));
|
||||||
|
|
||||||
|
const toGalleryItem = (item) => ({
|
||||||
|
id: item._id || item.id,
|
||||||
|
name: item.title || item.fileName || item.originalName || 'فایل',
|
||||||
|
fileName: item.fileName || item.originalName,
|
||||||
|
title: item.title,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
url: item.url || item.signedUrl || item.presignedUrl || item.fileUrl,
|
||||||
|
signedUrl: item.signedUrl || item.presignedUrl,
|
||||||
|
fileUrl: item.fileUrl || null,
|
||||||
|
pending: false,
|
||||||
|
width: 1600,
|
||||||
|
height: 1200
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
if (!props.userId) return;
|
||||||
|
try {
|
||||||
|
const [certsRes, docsRes] = await Promise.all([
|
||||||
|
certificateApi.getByUser(props.userId),
|
||||||
|
documentApi.getByUser(props.userId)
|
||||||
|
]);
|
||||||
|
const certs = certsRes.data || certsRes;
|
||||||
|
const docs = docsRes.data || docsRes;
|
||||||
|
certificateItems.value = (Array.isArray(certs) ? certs : []).map(toGalleryItem);
|
||||||
|
documentItems.value = (Array.isArray(docs) ? docs : []).map(toGalleryItem);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCertificates = async () => {
|
||||||
|
if (!pendingCertificates.value.length) return;
|
||||||
|
savingCertificates.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of pendingCertificates.value) {
|
||||||
|
await certificateApi.create({
|
||||||
|
user: props.userId,
|
||||||
|
tempFileName: file.tempFileName,
|
||||||
|
originalName: file.fileName || file.name,
|
||||||
|
fileName: file.fileName || file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
title: file.name || 'گواهینامه'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showSuccess('گواهینامهها با موفقیت ثبت شدند');
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingCertificates.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitDocuments = async () => {
|
||||||
|
if (!pendingDocuments.value.length) return;
|
||||||
|
savingDocuments.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of pendingDocuments.value) {
|
||||||
|
await documentApi.create({
|
||||||
|
user: props.userId,
|
||||||
|
tempFileName: file.tempFileName,
|
||||||
|
originalName: file.fileName || file.name,
|
||||||
|
fileName: file.fileName || file.name,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
title: documentTitle.value || file.name || 'سند'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
documentTitle.value = '';
|
||||||
|
showSuccess('اسناد با موفقیت ثبت شدند');
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingDocuments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCertificateRemove = async (item) => {
|
||||||
|
if (!item?.id || item.pending) return;
|
||||||
|
try {
|
||||||
|
await certificateApi.delete(item.id);
|
||||||
|
showSuccess('گواهینامه حذف شد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
await loadFiles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentRemove = async (item) => {
|
||||||
|
if (!item?.id || item.pending) return;
|
||||||
|
try {
|
||||||
|
await documentApi.delete(item.id);
|
||||||
|
showSuccess('سند حذف شد');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
await loadFiles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.userId,
|
||||||
|
() => loadFiles()
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(loadFiles);
|
||||||
|
|
||||||
|
defineExpose({ reload: loadFiles });
|
||||||
|
</script>
|
||||||
@@ -42,6 +42,11 @@ export const routes = [
|
|||||||
name: 'UserEdit',
|
name: 'UserEdit',
|
||||||
component: () => import('@/views/users/UserFormView.vue')
|
component: () => import('@/views/users/UserFormView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'users/:id',
|
||||||
|
name: 'UserDetail',
|
||||||
|
component: () => import('@/views/users/UserDetailView.vue')
|
||||||
|
},
|
||||||
|
|
||||||
// Professors
|
// Professors
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
|
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
|
||||||
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
|
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
|
||||||
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
|
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
|
||||||
<Tab value="4">{{ $t('users.tabCertificates') }}</Tab>
|
<Tab value="4">گواهینامهها و اسناد</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- Tab 1: User Info -->
|
<!-- Tab 1: User Info -->
|
||||||
@@ -180,11 +180,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 5: Certificates -->
|
<!-- Tab 5: Certificates & Documents -->
|
||||||
<TabPanel value="4">
|
<TabPanel value="4">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<h3 class="text-lg font-bold mb-3">گواهینامههای صادر شده دانشجو</h3>
|
<UserFilesSection :user-id="String(userId)" />
|
||||||
<CertificateUploader v-model="userCertificates" />
|
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
@@ -221,7 +220,7 @@ import { useToast } from '@/composables/useToast';
|
|||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import CertificateUploader from '@/components/uploader/CertificateUploader.vue';
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
import Tabs from 'primevue/tabs';
|
import Tabs from 'primevue/tabs';
|
||||||
import TabList from 'primevue/tablist';
|
import TabList from 'primevue/tablist';
|
||||||
import Tab from 'primevue/tab';
|
import Tab from 'primevue/tab';
|
||||||
@@ -243,7 +242,6 @@ const user = ref(null);
|
|||||||
const enrolledCourses = ref([]);
|
const enrolledCourses = ref([]);
|
||||||
const attendances = ref([]);
|
const attendances = ref([]);
|
||||||
const payments = ref([]);
|
const payments = ref([]);
|
||||||
const userCertificates = ref([]);
|
|
||||||
|
|
||||||
const showEnrollModal = ref(false);
|
const showEnrollModal = ref(false);
|
||||||
const allCourses = ref([]);
|
const allCourses = ref([]);
|
||||||
@@ -263,7 +261,6 @@ const fetchUserDetail = async () => {
|
|||||||
user.value = res.data || res;
|
user.value = res.data || res;
|
||||||
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
|
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
|
||||||
payments.value = user.value.payments || [];
|
payments.value = user.value.payments || [];
|
||||||
userCertificates.value = user.value.certificates || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,10 @@
|
|||||||
<AdminNotesField v-model="form.adminNotes" />
|
<AdminNotesField v-model="form.adminNotes" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditMode" class="col-12 mt-2">
|
||||||
|
<UserFilesSection :user-id="String(userId)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
|
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
|
||||||
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
|
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
|
||||||
<InputText
|
<InputText
|
||||||
@@ -171,6 +175,7 @@ import { roleApi } from '@/api/roleApi';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
||||||
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
|
|||||||
Reference in New Issue
Block a user