feat: add settings data upload and single-name user forms

Allow SuperAdmins to upload import JSON from settings, and update user UI for merged name, gender, and extended profile fields.
This commit is contained in:
2026-08-15 01:31:47 +03:30
parent 833584b1b4
commit 6b5549019c
11 changed files with 268 additions and 27 deletions
@@ -31,7 +31,7 @@
<Column field="name" header="نام و نام خانوادگی دانشجو">
<template #body="{ data }">
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
<span class="font-bold text-color">{{ data.name }}</span>
<span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span>
</template>
</Column>
@@ -121,7 +121,8 @@ const fetchSessionAttendance = async () => {
return {
userId: user._id,
name: user.name || '',
surname: user.surname || '',
name: user.name || '',
gender: user.gender || null,
nationalIdCode: user.nationalIdCode || '',
status: existing?.status || 'present',
note: existing?.note || ''
+1 -1
View File
@@ -47,7 +47,7 @@
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
<template #body="{ data }">{{ data.name }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
+2 -2
View File
@@ -97,7 +97,7 @@
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="نام">
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
<template #body="{ data }">{{ data.name }}</template>
</Column>
<Column header="موبایل">
<template #body="{ data }">
@@ -176,7 +176,7 @@ const availableUsers = computed(() => {
.filter((u) => !enrolled.has(String(u._id)))
.map((u) => ({
...u,
label: `${u.name || ''} ${u.surname || ''}${u.phoneNumber || ''}`.trim()
label: `${u.name || ''}${u.phoneNumber || ''}`.trim()
}));
});
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="flex flex-column gap-3">
<div>
<span class="text-muted text-xs block mb-1">نام کاربر / دانشجو</span>
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }} {{ payment.user?.surname || '' }}</span>
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }}</span>
</div>
<div v-if="payment.classes && payment.classes.length">
<span class="text-muted text-xs block mb-1">کلاسهای مربوطه</span>
+2 -2
View File
@@ -22,7 +22,7 @@
<Column field="user" header="نام دانشجو / کاربر">
<template #body="{ data }">
<router-link :to="`/payments/view/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.user?.name || data.userName || 'کاربر' }} {{ data.user?.surname || '' }}
{{ data.user?.name || data.userName || 'کاربر' }}
</router-link>
</template>
</Column>
@@ -196,7 +196,7 @@ const fetchDropdownData = async () => {
]);
const uData = uRes.data || uRes;
const rawUsers = uData.items || uData.users || uData || [];
usersList.value = rawUsers.map(u => ({ ...u, fullName: `${u.name} ${u.surname}` }));
usersList.value = rawUsers.map(u => ({ ...u, fullName: u.name || '' }));
const cData = cRes.data || cRes;
classesList.value = cData.items || cData.classes || cData || [];
+112 -2
View File
@@ -3,10 +3,10 @@
<div class="settings-view w-full max-w-3xl mx-auto">
<PageHeader
title="تنظیمات سیستم"
subtitle="عملیات راه‌اندازی و پیکربندی اولیه پایگاه داده"
subtitle="عملیات راه‌اندازی و وارد کردن داده‌های دوره‌ها و کاربران"
/>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm">
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4">
<div class="flex align-items-start gap-3 mb-4">
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i class="pi pi-database text-xl"></i>
@@ -45,6 +45,67 @@
</span>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm">
<div class="flex align-items-start gap-3 mb-4">
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i class="pi pi-upload text-xl"></i>
</div>
<div class="flex-grow-1">
<h2 class="text-lg font-bold text-color m-0 mb-1">وارد کردن دادهها</h2>
<p class="text-sm text-muted m-0 line-height-3">
فایل JSON ساختاریافته دورهها، کلاسها و کاربران را بارگذاری کنید. دادههای قبلی حذف نمیشوند؛ موارد تکراری بهروزرسانی یا به کلاس اضافه میشوند.
</p>
</div>
</div>
<div class="flex flex-column gap-3">
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="onFileSelected"
/>
<div class="flex flex-column sm:flex-row align-items-stretch sm:align-items-center gap-3">
<Button
:label="selectedFileName || 'انتخاب فایل JSON'"
:icon="selectedFileName ? 'pi pi-file' : 'pi pi-folder-open'"
severity="secondary"
outlined
class="font-semibold"
@click="fileInputRef?.click()"
/>
<Button
label="بارگذاری و افزودن به پایگاه داده"
icon="pi pi-cloud-upload"
class="font-bold"
:loading="isImporting"
:disabled="!selectedFile"
@click="confirmImport"
/>
</div>
<p class="text-xs text-muted m-0 line-height-3">
نمونه فایل آماده: <code dir="ltr">raw-data/import-data.json</code>
</p>
<div
v-if="importResult"
class="p-3 border-round surface-ground border-1 border-color text-sm"
>
<div class="font-semibold mb-2">نتیجه آخرین بارگذاری</div>
<ul class="m-0 pr-3 line-height-3">
<li>دوره جدید: {{ importResult.stats?.coursesCreated ?? 0 }} بازاستفاده: {{ importResult.stats?.coursesReused ?? 0 }}</li>
<li>کلاس جدید: {{ importResult.stats?.classesCreated ?? 0 }} بازاستفاده: {{ importResult.stats?.classesReused ?? 0 }}</li>
<li>کاربر جدید: {{ importResult.stats?.studentsCreated ?? 0 }} بهروزرسانی: {{ importResult.stats?.studentsUpdated ?? 0 }}</li>
<li>ثبتنام در کلاس: {{ importResult.stats?.enrollmentsAdded ?? 0 }} رد شده: {{ importResult.stats?.studentsSkipped ?? 0 }}</li>
<li v-if="importResult.warnings?.length">هشدارها: {{ importResult.warnings.length }}</li>
</ul>
</div>
</div>
</div>
</div>
</template>
@@ -64,6 +125,12 @@ const isSeeding = ref(false);
const isLocked = ref(false);
const lockedAt = ref(null);
const fileInputRef = ref(null);
const selectedFile = ref(null);
const selectedFileName = ref('');
const isImporting = ref(false);
const importResult = ref(null);
const lockedAtLabel = computed(() => {
if (!lockedAt.value) return '';
const date = new Date(lockedAt.value);
@@ -118,5 +185,48 @@ const confirmSeed = () => {
});
};
const onFileSelected = (event) => {
const file = event.target.files?.[0] || null;
selectedFile.value = file;
selectedFileName.value = file?.name || '';
importResult.value = null;
};
const runImport = async () => {
if (!selectedFile.value) return;
isImporting.value = true;
try {
const response = await seedApi.importData(selectedFile.value);
const data = response?.data || response;
importResult.value = data;
showSuccess('داده‌ها با موفقیت به پایگاه داده اضافه شدند.');
} catch (err) {
showError(err);
} finally {
isImporting.value = false;
}
};
const confirmImport = () => {
if (!selectedFile.value) return;
confirm.require({
header: 'وارد کردن داده‌ها',
message: 'دوره‌ها، کلاس‌ها و کاربران از فایل JSON به پایگاه داده افزوده می‌شوند. داده‌های موجود حذف نمی‌شوند. ادامه می‌دهید؟',
icon: 'pi pi-upload',
acceptLabel: 'بارگذاری',
rejectLabel: 'انصراف',
acceptClass: 'p-button-primary',
accept: () => {
runImport();
}
});
};
onMounted(loadStatus);
</script>
<style scoped>
.hidden {
display: none;
}
</style>
+31 -3
View File
@@ -1,7 +1,7 @@
<!-- /src/views/users/UserDetailView.vue -->
<template>
<div class="user-detail-view" v-if="user">
<PageHeader :title="`${user.name || ''} ${user.surname || ''}`" :subtitle="`کد ملی: ${toPersianDigits(user.nationalId)}`">
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalId)}`">
<PermissionGate permission="users:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
</PermissionGate>
@@ -22,8 +22,12 @@
<TabPanel value="0">
<div class="grid p-3">
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">نام و نام خانوادگی</span>
<span class="font-bold text-color text-base">{{ user.name }} {{ user.surname }}</span>
<span class="text-muted text-xs block mb-1">نام</span>
<span class="font-bold text-color text-base">{{ user.name }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">جنسیت</span>
<span class="font-bold text-color text-base">{{ user.gender === 'male' ? 'مرد' : user.gender === 'female' ? 'زن' : '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">کد ملی</span>
@@ -52,6 +56,30 @@
<span class="text-muted text-xs block mb-1">نقش سیستم</span>
<Tag :value="user.role?.name || 'دانشجو'" severity="secondary" />
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">نام پدر</span>
<span class="font-bold text-color text-base">{{ user.fatherName || '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">تاریخ تولد</span>
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthDate ? new Date(user.birthDate).toLocaleDateString('fa-IR') : '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">شماره شناسنامه</span>
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthCertificateNumber || '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">محل صدور</span>
<span class="font-bold text-color text-base">{{ user.placeOfIssue || '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">کد پستی</span>
<span class="font-bold text-color text-base" dir="ltr">{{ user.postalCode || '-' }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">تحصیلات</span>
<span class="font-bold text-color text-base">{{ user.education || '-' }}</span>
</div>
<div class="col-12 mb-3">
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
<span class="text-color text-sm">{{ user.address || '-' }}</span>
+96 -10
View File
@@ -12,12 +12,20 @@
<form @submit.prevent="handleSubmit" class="grid">
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
<InputText v-model.trim="form.name" class="w-full text-sm" />
<InputText v-model.trim="form.name" class="w-full text-sm" placeholder="نام کامل" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
<InputText v-model.trim="form.surname" class="w-full text-sm" />
<label class="font-semibold text-sm">{{ $t('users.gender') }}</label>
<Dropdown
v-model="form.gender"
:options="genderOptions"
optionLabel="label"
optionValue="value"
placeholder="انتخاب جنسیت"
class="w-full text-sm"
showClear
/>
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
@@ -40,6 +48,41 @@
<Dropdown v-model="form.role" :options="roles" optionLabel="name" optionValue="_id" placeholder="انتخاب نقش" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.fatherName') }}</label>
<InputText v-model.trim="form.fatherName" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.birthCertificateNumber') }}</label>
<InputText v-model.trim="form.birthCertificateNumber" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.birthDate') }}</label>
<InputText v-model.trim="form.birthDate" class="w-full text-sm" dir="ltr" placeholder="YYYY-MM-DD" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.placeOfIssue') }}</label>
<InputText v-model.trim="form.placeOfIssue" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.postalCode') }}</label>
<InputText v-model.trim="form.postalCode" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.education') }}</label>
<InputText v-model.trim="form.education" class="w-full text-sm" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.parentPhoneNumber') }}</label>
<InputText v-model.trim="form.parentPhoneNumber" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.preferredMessenger') }}</label>
<MultiSelect
@@ -56,6 +99,11 @@
/>
</div>
<div class="col-12 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.address') }}</label>
<InputText v-model.trim="form.address" class="w-full text-sm" />
</div>
<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>
<InputText
@@ -143,19 +191,39 @@ const messengerOptions = [
{ label: 'ایمیل', value: 'Email' }
];
const genderOptions = [
{ label: 'مرد', value: 'male' },
{ label: 'زن', value: 'female' }
];
const toMessengerList = (value) => {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value];
};
const toDateInput = (value) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
};
const form = reactive({
name: '',
surname: '',
gender: null,
nationalId: '',
phone: '',
email: '',
role: null,
preferredMessenger: [],
fatherName: '',
birthCertificateNumber: '',
birthDate: '',
placeOfIssue: '',
postalCode: '',
education: '',
parentPhoneNumber: '',
address: '',
username: '',
password: ''
});
@@ -163,11 +231,19 @@ const form = reactive({
const buildPayload = () => {
const payload = {
name: form.name,
surname: form.surname,
gender: form.gender || undefined,
nationalIdCode: form.nationalId,
phoneNumber: form.phone,
email: form.email || undefined,
role: form.role || undefined
role: form.role || undefined,
fatherName: form.fatherName || undefined,
birthCertificateNumber: form.birthCertificateNumber || undefined,
birthDate: form.birthDate || undefined,
placeOfIssue: form.placeOfIssue || undefined,
postalCode: form.postalCode || undefined,
education: form.education || undefined,
parentPhoneNumber: form.parentPhoneNumber || undefined,
address: form.address || undefined
};
if (form.preferredMessenger?.length) {
@@ -200,14 +276,24 @@ const fetchUser = async () => {
try {
const res = await userApi.getOne(userId);
const data = res.data || res;
const legacySurname = data.surname ? String(data.surname).trim() : '';
const fullName = [data.name, legacySurname].filter(Boolean).join(' ').trim();
Object.assign(form, {
name: data.name || '',
surname: data.surname || '',
name: fullName,
gender: data.gender || null,
nationalId: data.nationalIdCode || data.nationalId || '',
phone: data.phoneNumber || data.phone || '',
email: data.email || '',
role: data.role?._id || data.role || null,
preferredMessenger: toMessengerList(data.preferredMessenger),
fatherName: data.fatherName || '',
birthCertificateNumber: data.birthCertificateNumber || '',
birthDate: toDateInput(data.birthDate),
placeOfIssue: data.placeOfIssue || '',
postalCode: data.postalCode || '',
education: data.education || '',
parentPhoneNumber: data.parentPhoneNumber || '',
address: data.address || '',
username: data.username || '',
password: ''
});
@@ -222,8 +308,8 @@ const finishAfterCreate = () => {
};
const handleSubmit = async () => {
if (!form.name || !form.surname) {
showError('نام و نام خانوادگی الزامی است');
if (!form.name) {
showError('نام الزامی است');
return;
}
if (!form.nationalId || !form.phone) {
+2 -2
View File
@@ -19,10 +19,10 @@
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="name" header="نام و نام خانوادگی" sortable>
<Column field="name" header="نام" sortable>
<template #body="{ data }">
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
{{ data.name }} {{ data.surname }}
{{ data.name }}
</router-link>
</template>
</Column>