feat: add messaging toggles, password reset UI, and payment discounts

Add notification channel toggles to settings, password reset SMS action on user form, and discount/payable amount display on payment views.
This commit is contained in:
2026-08-16 06:58:09 +03:30
parent 5fe7eb090e
commit 92eb394466
8 changed files with 361 additions and 21 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test src/utils/uploadResponse.test.js src/utils/classSchedule.test.js" "test": "node --test src/utils/uploadResponse.test.js src/utils/classSchedule.test.js src/utils/paymentAmount.test.js"
}, },
"dependencies": { "dependencies": {
"@fontsource/vazirmatn": "^5.3.0", "@fontsource/vazirmatn": "^5.3.0",
+5
View File
@@ -7,6 +7,11 @@ export const userApi = {
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`), getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/users/admin/create', data), create: (data) => axiosInstance.post('/users/admin/create', data),
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data), update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
resetPasswordAndSms: (id) => axiosInstance.post(
`/users/admin/${id}/reset-password-sms`,
{},
{ timeout: 30000 }
),
delete: (id) => axiosInstance.delete(`/users/admin/delete/${id}`), delete: (id) => axiosInstance.delete(`/users/admin/delete/${id}`),
enroll: (userId, data) => axiosInstance.post(`/users/admin/${userId}/enroll`, data) enroll: (userId, data) => axiosInstance.post(`/users/admin/${userId}/enroll`, data)
}; };
+7
View File
@@ -0,0 +1,7 @@
export const getPayableAmount = (payment = {}) => {
const amount = Number(payment.amount);
const discount = Number(payment.discount);
const safeAmount = Number.isFinite(amount) && amount > 0 ? amount : 0;
const safeDiscount = Number.isFinite(discount) && discount > 0 ? discount : 0;
return Math.max(0, safeAmount - Math.min(safeDiscount, safeAmount));
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getPayableAmount } from './paymentAmount.js';
describe('getPayableAmount', () => {
it('returns the original amount when there is no discount', () => {
assert.equal(getPayableAmount({ amount: 1_000_000 }), 1_000_000);
assert.equal(getPayableAmount({ amount: 1_000_000, discount: 0 }), 1_000_000);
});
it('subtracts a discount from the total', () => {
assert.equal(getPayableAmount({ amount: 1_000_000, discount: 150_000 }), 850_000);
});
it('never returns a negative payable amount', () => {
assert.equal(getPayableAmount({ amount: 100, discount: 250 }), 0);
});
});
+38 -4
View File
@@ -27,7 +27,15 @@
</div> </div>
<div> <div>
<span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span> <span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span>
<span class="font-bold text-color text-xl">{{ toPersianDigits(payment.amount?.toLocaleString()) }} تومان</span> <span class="font-bold text-color text-xl">{{ toPersianDigits((payment.amount || 0).toLocaleString()) }} تومان</span>
</div>
<div v-if="payment.discount">
<span class="text-muted text-xs block mb-1">تخفیف</span>
<span class="font-semibold text-orange-500 text-lg">{{ toPersianDigits((payment.discount || 0).toLocaleString()) }} تومان</span>
</div>
<div>
<span class="text-muted text-xs block mb-1">مبلغ قابل پرداخت</span>
<span class="font-bold text-color text-xl">{{ toPersianDigits(payableAmount.toLocaleString()) }} تومان</span>
</div> </div>
<div> <div>
<span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span> <span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span>
@@ -35,12 +43,16 @@
</div> </div>
<div> <div>
<span class="text-muted text-xs block mb-1">باقیمانده</span> <span class="text-muted text-xs block mb-1">باقیمانده</span>
<span class="font-bold text-red-500 text-lg">{{ toPersianDigits((payment.amount - (payment.paidAmount || 0)).toLocaleString()) }} تومان</span> <span class="font-bold text-red-500 text-lg">{{ toPersianDigits(remainingAmount.toLocaleString()) }} تومان</span>
</div> </div>
<div> <div>
<span class="text-muted text-xs block mb-1">وضعیت پرداخت</span> <span class="text-muted text-xs block mb-1">وضعیت پرداخت</span>
<StatusTag :status="payment.status" type="payment" /> <StatusTag :status="payment.status" type="payment" />
</div> </div>
<div>
<span class="text-muted text-xs block mb-1">یادداشت</span>
<p class="text-sm text-color m-0 white-space-pre-wrap line-height-3">{{ payment.notes || '—' }}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -76,6 +88,11 @@
{{ formatJalali(data.createdAt || data.date) }} {{ formatJalali(data.createdAt || data.date) }}
</template> </template>
</Column> </Column>
<Column field="notes" header="یادداشت">
<template #body="{ data }">
<span class="white-space-pre-wrap">{{ data.notes || '—' }}</span>
</template>
</Column>
</DataTable> </DataTable>
</div> </div>
</div> </div>
@@ -96,6 +113,16 @@
<label class="font-semibold text-sm">شماره فیش / پیگیری *</label> <label class="font-semibold text-sm">شماره فیش / پیگیری *</label>
<InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" /> <InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" />
</div> </div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="trx-notes">یادداشت</label>
<Textarea
id="trx-notes"
v-model="trxForm.notes"
rows="3"
class="w-full text-sm"
placeholder="یادداشت داخلی درباره این تراکنش…"
/>
</div>
</div> </div>
<template #footer> <template #footer>
<Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" /> <Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" />
@@ -106,11 +133,12 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue'; import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { paymentApi } from '@/api/paymentApi'; import { paymentApi } from '@/api/paymentApi';
import { usePersianDate } from '@/composables/usePersianDate'; import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { getPayableAmount } from '@/utils/paymentAmount';
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';
@@ -122,6 +150,7 @@ import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select'; import Dropdown from 'primevue/select';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
const route = useRoute(); const route = useRoute();
const paymentId = route.params.id; const paymentId = route.params.id;
@@ -141,9 +170,13 @@ const methodOptions = [
const trxForm = reactive({ const trxForm = reactive({
amount: 500000, amount: 500000,
method: 'card', method: 'card',
receiptNumber: '' receiptNumber: '',
notes: ''
}); });
const payableAmount = computed(() => getPayableAmount(payment.value || {}));
const remainingAmount = computed(() => Math.max(0, payableAmount.value - (payment.value?.paidAmount || 0)));
const fetchPayment = async () => { const fetchPayment = async () => {
try { try {
const res = await paymentApi.getOne(paymentId); const res = await paymentApi.getOne(paymentId);
@@ -162,6 +195,7 @@ const handleRecordTransaction = async () => {
await paymentApi.recordTransaction(paymentId, trxForm); await paymentApi.recordTransaction(paymentId, trxForm);
showSuccess('تراکنش جدید با موفقیت ثبت شد'); showSuccess('تراکنش جدید با موفقیت ثبت شد');
showTransactionModal.value = false; showTransactionModal.value = false;
trxForm.notes = '';
fetchPayment(); fetchPayment();
} catch (err) { } catch (err) {
showError(err); showError(err);
+61 -5
View File
@@ -39,7 +39,10 @@
<Column field="amount" header="مبلغ کل (تومان)" sortable> <Column field="amount" header="مبلغ کل (تومان)" sortable>
<template #body="{ data }"> <template #body="{ data }">
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان <div>{{ toPersianDigits(getPayableAmount(data).toLocaleString()) }} تومان</div>
<small v-if="data.discount" class="text-muted text-xs">
تخفیف {{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان
</small>
</template> </template>
</Column> </Column>
@@ -77,7 +80,7 @@
</DataTableWrapper> </DataTableWrapper>
<!-- Create Payment Modal --> <!-- Create Payment Modal -->
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '480px' }"> <Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '520px' }">
<div class="flex flex-column gap-3 py-2"> <div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس *</label> <label class="font-semibold text-sm">انتخاب یک یا چند کلاس *</label>
@@ -112,8 +115,28 @@
</div> </div>
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ کل صورتحساب (تومان) *</label> <label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
<InputNumber v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" /> <InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
</div>
<div class="flex align-items-center gap-2">
<Checkbox v-model="hasDiscount" binary inputId="invoice-discount" />
<label for="invoice-discount" class="font-semibold text-sm cursor-pointer">تخفیف ؟</label>
</div>
<div v-if="hasDiscount" class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label>
<InputNumber
inputId="invoice-discount-amount"
v-model="createForm.discount"
class="w-full text-sm"
suffix=" تومان"
:min="0"
:max="createForm.amount || 0"
/>
<small class="font-semibold text-color">
مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان
</small>
</div> </div>
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
@@ -121,6 +144,17 @@
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" /> <DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
</div> </div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="invoice-notes">یادداشت</label>
<Textarea
id="invoice-notes"
v-model="createForm.notes"
rows="3"
class="w-full text-sm"
placeholder="یادداشت داخلی درباره این صورتحساب…"
/>
</div>
<NotifyChannelsField :notify="createNotify" /> <NotifyChannelsField :notify="createNotify" />
</div> </div>
<template #footer> <template #footer>
@@ -151,6 +185,7 @@ import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.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 NotifyChannelsField from '@/components/common/NotifyChannelsField.vue'; import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
import { getPayableAmount } from '@/utils/paymentAmount';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Column from 'primevue/column'; import Column from 'primevue/column';
import Tag from 'primevue/tag'; import Tag from 'primevue/tag';
@@ -158,6 +193,8 @@ import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select'; import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect'; import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import Checkbox from 'primevue/checkbox';
import Textarea from 'primevue/textarea';
import DatePicker from 'vue3-persian-datetime-picker'; import DatePicker from 'vue3-persian-datetime-picker';
const { toPersianDigits, formatJalali } = usePersianDate(); const { toPersianDigits, formatJalali } = usePersianDate();
@@ -183,9 +220,16 @@ const createForm = reactive({
user: null, user: null,
classes: [], classes: [],
amount: 0, amount: 0,
discount: 0,
notes: '',
dueDate: '' dueDate: ''
}); });
const hasDiscount = ref(false);
const createNotify = reactive({ sms: true, email: true, bot: true }); const createNotify = reactive({ sms: true, email: true, bot: true });
const createPayableAmount = computed(() => getPayableAmount({
amount: createForm.amount,
discount: hasDiscount.value ? createForm.discount : 0
}));
const deleteDialogVisible = ref(false); const deleteDialogVisible = ref(false);
const selectedPayment = ref(null); const selectedPayment = ref(null);
@@ -241,7 +285,10 @@ const resetCreateForm = () => {
createForm.user = null; createForm.user = null;
createForm.classes = []; createForm.classes = [];
createForm.amount = 0; createForm.amount = 0;
createForm.discount = 0;
createForm.notes = '';
createForm.dueDate = ''; createForm.dueDate = '';
hasDiscount.value = false;
createNotify.sms = true; createNotify.sms = true;
createNotify.email = true; createNotify.email = true;
createNotify.bot = true; createNotify.bot = true;
@@ -275,9 +322,18 @@ const handleCreatePayment = async () => {
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; } if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; } if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
if (hasDiscount.value && (createForm.discount || 0) > createForm.amount) {
showError('مبلغ تخفیف نمی‌تواند بیشتر از مبلغ کل باشد');
return;
}
isCreating.value = true; isCreating.value = true;
try { try {
await paymentApi.create({ ...createForm, notify: { ...createNotify } }); await paymentApi.create({
...createForm,
discount: hasDiscount.value ? (createForm.discount || 0) : 0,
notify: { ...createNotify }
});
showSuccess('صورتحساب جدید با موفقیت ایجاد شد'); showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
showCreateModal.value = false; showCreateModal.value = false;
loadData(); loadData();
+136 -3
View File
@@ -3,9 +3,76 @@
<div class="settings-view w-full max-w-4xl mx-auto"> <div class="settings-view w-full max-w-4xl mx-auto">
<PageHeader <PageHeader
title="تنظیمات سیستم" title="تنظیمات سیستم"
subtitle="قالب‌های پیامک، راه‌اندازی پایگاه داده و وارد کردن داده‌ها" subtitle="کانال‌های اطلاع‌رسانی، قالب‌های پیامک، راه‌اندازی پایگاه داده و وارد کردن داده‌ها"
/> />
<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-bell 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">
ارسال پیامک، ایمیل و پیام ربات را از داشبورد فعال یا غیرفعال کنید. هر کانال فقط وقتی ارسال میشود که هم اینجا و هم متغیر محیطی سرور فعال باشد.
</p>
</div>
</div>
<div v-if="isSettingsLoading" class="flex align-items-center gap-2 text-muted text-sm">
<i class="pi pi-spin pi-spinner"></i>
<span>در حال بارگذاری تنظیمات کانالها</span>
</div>
<div v-else class="flex flex-column gap-3">
<div
v-for="channel in messagingChannels"
:key="channel.key"
class="p-4 border-round-lg surface-ground border-1 border-color flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3"
>
<div class="flex align-items-start gap-3">
<div class="w-2rem h-2rem border-round-lg flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i :class="channel.icon"></i>
</div>
<div>
<label class="font-bold text-base text-color cursor-pointer" :for="`messaging-${channel.key}`">
{{ channel.label }}
</label>
<p class="text-xs text-muted m-0 mt-1 line-height-3">
متغیر محیطی: <code dir="ltr">{{ channel.envName }}</code>
</p>
</div>
</div>
<div class="flex align-items-center gap-3 flex-shrink-0">
<Tag
:value="channelStatus(channel.key).label"
:severity="channelStatus(channel.key).severity"
class="text-xs"
/>
<InputSwitch
:inputId="`messaging-${channel.key}`"
v-model="messagingForm[channel.flag]"
/>
</div>
</div>
<p class="text-xs text-muted m-0 line-height-3">
اگر وضعیت «غیرفعال در سرور» باشد، روشن کردن سوییچ داشبورد بهتنهایی کافی نیست و باید متغیر محیطی مربوط هم فعال شود.
</p>
<div class="flex justify-content-end">
<Button
label="ذخیره کانال‌ها"
icon="pi pi-save"
class="font-bold"
:loading="isSavingMessaging"
@click="saveMessaging"
/>
</div>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4"> <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="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"> <div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
@@ -253,6 +320,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputSwitch from 'primevue/toggleswitch';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import Select from 'primevue/select'; import Select from 'primevue/select';
import Tag from 'primevue/tag'; import Tag from 'primevue/tag';
@@ -266,8 +334,34 @@ const { showSuccess, showError } = useToast();
const isSettingsLoading = ref(true); const isSettingsLoading = ref(true);
const isSavingSettings = ref(false); const isSavingSettings = ref(false);
const isSavingMessaging = ref(false);
const smsTemplates = ref([]); const smsTemplates = ref([]);
const templateForm = ref({}); const templateForm = ref({});
const messagingForm = ref({
smsEnabled: true,
emailEnabled: true,
botEnabled: true
});
const messagingEnv = ref({
smsEnabled: false,
emailEnabled: true,
botEnabled: true
});
const messagingChannels = [
{ key: 'sms', flag: 'smsEnabled', label: 'پیامک', icon: 'pi pi-mobile', envName: 'SMS_ENABLED' },
{ key: 'email', flag: 'emailEnabled', label: 'ایمیل', icon: 'pi pi-envelope', envName: 'EMAIL_ENABLED' },
{ key: 'bot', flag: 'botEnabled', label: 'پیام ربات', icon: 'pi pi-comments', envName: 'BOT_ENABLED' }
];
const channelStatus = (channelKey) => {
const flag = `${channelKey}Enabled`;
const dbOn = messagingForm.value[flag] !== false;
const envOn = Boolean(messagingEnv.value[flag]);
if (envOn && dbOn) return { label: 'ارسال فعال', severity: 'success' };
if (!envOn) return { label: 'غیرفعال در سرور', severity: 'warn' };
return { label: 'غیرفعال از داشبورد', severity: 'secondary' };
};
const isStatusLoading = ref(true); const isStatusLoading = ref(true);
const isSeeding = ref(false); const isSeeding = ref(false);
@@ -485,11 +579,31 @@ const applySmsTemplates = (payload) => {
templateForm.value = newForm; templateForm.value = newForm;
}; };
const applyMessaging = (payload) => {
const data = payload?.data?.data || payload?.data || payload || {};
const messaging = data.messaging || {};
messagingForm.value = {
smsEnabled: messaging.smsEnabled !== false,
emailEnabled: messaging.emailEnabled !== false,
botEnabled: messaging.botEnabled !== false
};
messagingEnv.value = {
smsEnabled: Boolean(messaging.env?.smsEnabled),
emailEnabled: Boolean(messaging.env?.emailEnabled),
botEnabled: Boolean(messaging.env?.botEnabled)
};
};
const applySettings = (payload) => {
applySmsTemplates(payload);
applyMessaging(payload);
};
const loadSettings = async () => { const loadSettings = async () => {
isSettingsLoading.value = true; isSettingsLoading.value = true;
try { try {
const response = await settingsApi.get(); const response = await settingsApi.get();
applySmsTemplates(response); applySettings(response);
} catch (err) { } catch (err) {
showError(err); showError(err);
} finally { } finally {
@@ -497,6 +611,25 @@ const loadSettings = async () => {
} }
}; };
const saveMessaging = async () => {
isSavingMessaging.value = true;
try {
const response = await settingsApi.save({
messaging: {
smsEnabled: Boolean(messagingForm.value.smsEnabled),
emailEnabled: Boolean(messagingForm.value.emailEnabled),
botEnabled: Boolean(messagingForm.value.botEnabled)
}
});
applySettings(response);
showSuccess('تنظیمات کانال‌های اطلاع‌رسانی با موفقیت ذخیره شد.');
} catch (err) {
showError(err);
} finally {
isSavingMessaging.value = false;
}
};
const saveSmsTemplates = async () => { const saveSmsTemplates = async () => {
isSavingSettings.value = true; isSavingSettings.value = true;
try { try {
@@ -521,7 +654,7 @@ const saveSmsTemplates = async () => {
}; };
}); });
const response = await settingsApi.save({ smsTemplates: smsTemplatesPayload }); const response = await settingsApi.save({ smsTemplates: smsTemplatesPayload });
applySmsTemplates(response); applySettings(response);
showSuccess('قالب‌های پیامک با موفقیت ذخیره شدند.'); showSuccess('قالب‌های پیامک با موفقیت ذخیره شدند.');
} catch (err) { } catch (err) {
showError(err); showError(err);
+95 -8
View File
@@ -123,6 +123,24 @@
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" /> <InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
</div> </div>
<div v-if="isEditMode" class="col-12">
<div class="flex flex-column sm:flex-row sm:align-items-center sm:justify-content-between gap-3 surface-ground p-3 border-round">
<p class="text-sm text-color-secondary m-0 line-height-3">
رمز عبور را بازنشانی کنید و نام کاربری و رمز جدید را به شماره همراه کاربر پیامک کنید. نشستهای فعال او بسته میشود.
</p>
<Button
type="button"
label="بازنشانی رمز و ارسال پیامک"
icon="pi pi-send"
severity="warning"
outlined
:loading="isResettingPassword"
:disabled="!form.phone"
@click="confirmResetPasswordSms"
/>
</div>
</div>
<div v-if="!isEditMode" class="col-12"> <div v-if="!isEditMode" class="col-12">
<NotifyChannelsField :notify="createNotify" /> <NotifyChannelsField :notify="createNotify" />
</div> </div>
@@ -147,12 +165,12 @@
<Dialog <Dialog
v-model:visible="credentialsVisible" v-model:visible="credentialsVisible"
modal modal
header="حساب ساخته شد" :header="credentialsDialog.header"
:style="{ width: '28rem' }" :style="{ width: '28rem' }"
:closable="false" :closable="false"
> >
<p class="text-sm mb-3 line-height-3"> <p class="text-sm mb-3 line-height-3">
اطلاعات ورود ساخته شد و در صورت فعال بودن پیامک، برای کاربر ارسال میشود. این رمز فقط یکبار نمایش داده میشود: {{ credentialsDialog.message }}
</p> </p>
<div class="flex flex-column gap-2 surface-ground p-3 border-round"> <div class="flex flex-column gap-2 surface-ground p-3 border-round">
<div class="flex justify-content-between gap-2"> <div class="flex justify-content-between gap-2">
@@ -177,6 +195,7 @@ import { useRoute, useRouter } from 'vue-router';
import { userApi } from '@/api/userApi'; import { userApi } from '@/api/userApi';
import { roleApi } from '@/api/roleApi'; import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useConfirm } from 'primevue/useconfirm';
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 UserFilesSection from '@/components/uploader/UserFilesSection.vue';
@@ -189,17 +208,33 @@ import Dialog from 'primevue/dialog';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { showSuccess, showError } = useToast(); const confirm = useConfirm();
const { showSuccess, showError, showWarn } = useToast();
const userId = route.params.id; const userId = route.params.id;
const isEditMode = computed(() => !!userId); const isEditMode = computed(() => !!userId);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const isResettingPassword = ref(false);
const filesSection = ref(null); const filesSection = ref(null);
const roles = ref([]); const roles = ref([]);
const credentialsVisible = ref(false); const credentialsVisible = ref(false);
const credentialsMode = ref('create');
const createdCredentials = reactive({ username: '', password: '' }); const createdCredentials = reactive({ username: '', password: '' });
const createNotify = reactive({ sms: true, email: true, bot: true }); const createNotify = reactive({ sms: true, email: true, bot: true });
const credentialsDialog = computed(() => {
if (credentialsMode.value === 'reset') {
return {
header: 'رمز عبور بازنشانی شد',
message: 'رمز جدید ساخته شد و در صورت موفقیت پیامک، برای کاربر ارسال می‌شود. این رمز فقط یک‌بار نمایش داده می‌شود:'
};
}
return {
header: 'حساب ساخته شد',
message: 'اطلاعات ورود ساخته شد و در صورت فعال بودن پیامک، برای کاربر ارسال می‌شود. این رمز فقط یک‌بار نمایش داده می‌شود:'
};
});
const messengerOptions = [ const messengerOptions = [
{ label: 'پیامک (SMS)', value: 'SMS' }, { label: 'پیامک (SMS)', value: 'SMS' },
{ label: 'بله', value: 'Bale' }, { label: 'بله', value: 'Bale' },
@@ -324,9 +359,62 @@ const fetchUser = async () => {
const finishAfterCreate = () => { const finishAfterCreate = () => {
credentialsVisible.value = false; credentialsVisible.value = false;
if (credentialsMode.value === 'reset') {
form.password = '';
return;
}
router.push('/users'); router.push('/users');
}; };
const showGeneratedCredentials = (creds = {}, mode = 'create') => {
createdCredentials.username = creds.username || '';
createdCredentials.password = creds.password || '';
if (!createdCredentials.username || !createdCredentials.password) return false;
credentialsMode.value = mode;
credentialsVisible.value = true;
return true;
};
const confirmResetPasswordSms = () => {
if (!form.phone) {
showError('شماره همراه کاربر برای ارسال پیامک الزامی است');
return;
}
confirm.require({
header: 'بازنشانی رمز عبور',
message: `رمز عبور فعلی باطل می‌شود و نام کاربری به‌همراه رمز جدید به شماره ${form.phone} پیامک می‌گردد. نشست‌های فعال کاربر بسته خواهد شد. ادامه می‌دهید؟`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'بازنشانی و ارسال',
rejectLabel: 'انصراف',
acceptClass: 'p-button-warning',
accept: () => {
resetPasswordAndSendSms();
}
});
};
const resetPasswordAndSendSms = async () => {
isResettingPassword.value = true;
try {
const res = await userApi.resetPasswordAndSms(userId);
const data = res.data || res;
const creds = data.generatedCredentials || {};
if (data.smsSent) {
showSuccess('رمز عبور بازنشانی شد و پیامک اطلاعات ورود ارسال گردید');
} else {
showWarn('رمز عبور بازنشانی شد، اما ارسال پیامک انجام نشد. رمز جدید را از پنجره زیر یادداشت کنید.');
}
if (!showGeneratedCredentials(creds, 'reset')) {
showError('رمز جدید ساخته شد اما برای نمایش در دسترس نیست');
}
} catch (err) {
showError(err);
} finally {
isResettingPassword.value = false;
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name) { if (!form.name) {
showError('نام الزامی است'); showError('نام الزامی است');
@@ -351,12 +439,11 @@ const handleSubmit = async () => {
const res = await userApi.create({ ...payload, notify: { ...createNotify } }); const res = await userApi.create({ ...payload, notify: { ...createNotify } });
const data = res.data || res; const data = res.data || res;
const creds = data.generatedCredentials || {}; const creds = data.generatedCredentials || {};
createdCredentials.username = creds.username || data.username || '';
createdCredentials.password = creds.password || '';
showSuccess('کاربر جدید با موفقیت ایجاد شد'); showSuccess('کاربر جدید با موفقیت ایجاد شد');
if (createdCredentials.username && createdCredentials.password) { if (!showGeneratedCredentials({
credentialsVisible.value = true; username: creds.username || data.username || '',
} else { password: creds.password || ''
}, 'create')) {
router.push('/users'); router.push('/users');
} }
} }