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:
2026-08-15 02:53:59 +03:30
parent b8e206caab
commit 370d2fdad6
8 changed files with 392 additions and 97 deletions
+3
View File
@@ -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')
}; };
+12
View File
@@ -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>
+153 -65
View File
@@ -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>
+5
View File
@@ -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
{ {
+4 -7
View File
@@ -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);
} }
+5
View File
@@ -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';