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:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user