953 lines
41 KiB
Vue
953 lines
41 KiB
Vue
<!-- /src/views/settings/SettingsView.vue -->
|
||
<template>
|
||
<div class="settings-view w-full max-w-4xl mx-auto">
|
||
<PageHeader
|
||
title="تنظیمات سیستم"
|
||
subtitle="کانالهای اطلاعرسانی، تنظیمات اعلان، قالبهای پیامک، راهاندازی و وارد کردن دادهها"
|
||
/>
|
||
|
||
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||
<TabList>
|
||
<Tab value="0">کانالها</Tab>
|
||
<Tab value="1">اطلاعرسانی</Tab>
|
||
<Tab value="2">قالبهای پیامک</Tab>
|
||
<Tab value="3">راهاندازی</Tab>
|
||
<Tab value="4">وارد کردن داده</Tab>
|
||
</TabList>
|
||
|
||
<TabPanels>
|
||
<TabPanel value="0">
|
||
<div class="p-4 sm:p-5">
|
||
<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>
|
||
</TabPanel>
|
||
|
||
<TabPanel value="1">
|
||
<div class="p-4 sm:p-5">
|
||
<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-sliders-h 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-4">
|
||
<div
|
||
v-for="group in notificationSettings"
|
||
:key="group.key"
|
||
class="flex flex-column gap-3"
|
||
>
|
||
<h3 class="text-base font-bold text-color m-0 pb-2 border-bottom-1 border-color">{{ group.label }}</h3>
|
||
|
||
<div
|
||
v-for="action in group.actions"
|
||
:key="action.key"
|
||
class="p-4 border-round-lg surface-ground border-1 border-color flex flex-column gap-3"
|
||
>
|
||
<div class="flex flex-column sm:flex-row sm:align-items-start justify-content-between gap-2">
|
||
<div>
|
||
<div class="font-bold text-color">{{ action.label }}</div>
|
||
<p class="text-xs text-muted m-0 mt-1 line-height-3">{{ action.description }}</p>
|
||
<span class="text-xs text-muted font-mono">{{ action.key }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div
|
||
v-for="channel in notificationChannelDefs"
|
||
:key="`${action.key}-${channel.key}`"
|
||
class="col-12 md:col-4"
|
||
>
|
||
<div class="flex align-items-center justify-content-between p-3 border-round surface-card border-1 border-color h-full">
|
||
<div class="flex align-items-center gap-2">
|
||
<i :class="channel.icon" class="text-primary"></i>
|
||
<span class="text-sm font-semibold">{{ channel.label }}</span>
|
||
</div>
|
||
<InputSwitch
|
||
v-model="notificationForm[action.key][channel.key]"
|
||
:inputId="`notify-${action.key}-${channel.key}`"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-content-end">
|
||
<Button
|
||
label="ذخیره تنظیمات اطلاعرسانی"
|
||
icon="pi pi-save"
|
||
class="font-bold"
|
||
:loading="isSavingNotifications"
|
||
@click="saveNotificationSettings"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TabPanel>
|
||
|
||
<TabPanel value="2">
|
||
<div class="p-4 sm:p-5">
|
||
<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-send 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">
|
||
شناسه قالب و تعداد و نام متغیرهای sms.ir را بر اساس قالب پنل پیامک تنظیم کنید. میتوانید هر تعداد متغیر که میخواهید تعریف یا حذف نمایید.
|
||
</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-4">
|
||
<div
|
||
v-for="template in smsTemplates"
|
||
:key="template.key"
|
||
class="p-4 border-round-lg surface-ground border-1 border-color flex flex-column gap-3 transition-all transition-duration-200"
|
||
:class="{ 'opacity-80': templateForm[template.key] && !templateForm[template.key].enabled }"
|
||
>
|
||
<template v-if="templateForm[template.key]">
|
||
<div class="flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3 border-bottom-1 border-color pb-3">
|
||
<div class="flex align-items-center gap-3">
|
||
<div
|
||
class="w-2rem h-2rem border-round-lg flex align-items-center justify-content-center flex-shrink-0"
|
||
:class="templateForm[template.key].enabled ? 'bg-primary-light text-primary' : 'surface-200 text-muted'"
|
||
>
|
||
<i class="pi pi-bookmark"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex align-items-center gap-2 flex-wrap">
|
||
<label class="font-bold text-base text-color cursor-pointer" :for="`sms-toggle-${template.key}`">
|
||
{{ template.label }}
|
||
</label>
|
||
<Tag
|
||
:value="templateForm[template.key].enabled ? 'ارسال پیامک: فعال' : 'ارسال پیامک: غیرفعال'"
|
||
:severity="templateForm[template.key].enabled ? 'success' : 'secondary'"
|
||
class="text-xs font-semibold"
|
||
/>
|
||
</div>
|
||
<span class="text-xs text-muted font-mono block mt-1">{{ template.key }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex align-items-center gap-3 flex-shrink-0">
|
||
<span class="text-xs font-semibold text-color">
|
||
{{ templateForm[template.key].enabled ? 'فعال' : 'غیرفعال' }}
|
||
</span>
|
||
<InputSwitch
|
||
:inputId="`sms-toggle-${template.key}`"
|
||
v-model="templateForm[template.key].enabled"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!templateForm[template.key].enabled"
|
||
class="p-2 px-3 border-round surface-card border-1 border-dashed border-color text-xs text-muted flex align-items-center gap-2"
|
||
>
|
||
<i class="pi pi-info-circle text-orange-500 flex-shrink-0"></i>
|
||
<span>ارسال این نوع پیامک غیرفعال است و با وقوع رویداد مربوطه پیامکی ارسال نخواهد شد.</span>
|
||
</div>
|
||
|
||
<div class="flex flex-column sm:flex-row sm:align-items-center gap-2">
|
||
<label
|
||
class="text-sm font-semibold text-color sm:w-10rem flex-shrink-0"
|
||
:for="`sms-template-${template.key}`"
|
||
>
|
||
شناسه قالب sms.ir:
|
||
</label>
|
||
<div class="flex-grow-1">
|
||
<InputText
|
||
:id="`sms-template-${template.key}`"
|
||
v-model.trim="templateForm[template.key].templateId"
|
||
class="w-full text-sm"
|
||
dir="ltr"
|
||
inputmode="numeric"
|
||
placeholder="مثلاً: 123456"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-column gap-3 pt-2">
|
||
<div class="flex align-items-center justify-content-between">
|
||
<div>
|
||
<span class="text-sm font-semibold text-color">متغیرهای ارسالی در قالب</span>
|
||
<span class="text-xs text-muted block mt-1">متغیرهایی که در پنل sms.ir در متن قالب قرار دادهاید</span>
|
||
</div>
|
||
<Button
|
||
label="افزودن متغیر"
|
||
icon="pi pi-plus"
|
||
size="small"
|
||
outlined
|
||
class="text-xs font-semibold"
|
||
@click="addVariable(template.key)"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!templateForm[template.key]?.variables || templateForm[template.key]?.variables.length === 0"
|
||
class="p-3 text-center border-1 border-dashed border-round surface-card text-muted text-xs line-height-3"
|
||
>
|
||
هیچ متغیری برای این قالب تعریف نشده است. پیامک بدون متغیر ارسال خواهد شد.
|
||
</div>
|
||
|
||
<div v-else class="flex flex-column gap-2">
|
||
<div
|
||
v-for="(variable, vIdx) in templateForm[template.key].variables"
|
||
:key="variable.id || vIdx"
|
||
class="flex flex-column md:flex-row md:align-items-center gap-3 p-3 border-round surface-card border-1 border-color"
|
||
>
|
||
<div class="flex flex-column gap-1 md:w-16rem flex-shrink-0">
|
||
<span class="text-xs text-muted font-semibold">مقدار ارسالی از سیستم:</span>
|
||
<Select
|
||
v-model="variable.slot"
|
||
:options="getAvailableSlots(template.key)"
|
||
optionLabel="label"
|
||
optionValue="value"
|
||
placeholder="انتخاب مقدار داده"
|
||
class="w-full text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex-grow-1 flex flex-column gap-1">
|
||
<span class="text-xs text-muted font-semibold">نام متغیر در sms.ir:</span>
|
||
<div class="flex align-items-center gap-2">
|
||
<InputText
|
||
v-model.trim="variable.name"
|
||
class="w-full text-sm"
|
||
dir="ltr"
|
||
:placeholder="getSlotDefaultName(template.key, variable.slot) || 'نام متغیر در sms.ir'"
|
||
autocomplete="off"
|
||
/>
|
||
<Tag
|
||
:value="`#${variable.name || getSlotDefaultName(template.key, variable.slot) || '...'}#`"
|
||
severity="secondary"
|
||
class="text-xs flex-shrink-0 font-mono"
|
||
/>
|
||
<Button
|
||
icon="pi pi-trash"
|
||
severity="danger"
|
||
text
|
||
rounded
|
||
size="small"
|
||
class="p-button-sm flex-shrink-0"
|
||
title="حذف متغیر"
|
||
@click="removeVariable(template.key, vIdx)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="flex justify-content-end">
|
||
<Button
|
||
label="ذخیره قالبها"
|
||
icon="pi pi-save"
|
||
class="font-bold"
|
||
:loading="isSavingSettings"
|
||
@click="saveSmsTemplates"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TabPanel>
|
||
|
||
<TabPanel value="3">
|
||
<div class="p-4 sm:p-5">
|
||
<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>
|
||
</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="isStatusLoading" 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-if="isLocked" class="flex align-items-center gap-2 p-3 border-round surface-ground border-1 border-color">
|
||
<i class="pi pi-lock text-primary"></i>
|
||
<div class="flex flex-column gap-1">
|
||
<span class="font-semibold text-sm text-color">راهاندازی اولیه انجام شده است</span>
|
||
<span v-if="lockedAtLabel" class="text-xs text-muted">{{ lockedAtLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="flex flex-column sm:flex-row align-items-stretch sm:align-items-center gap-3">
|
||
<Button
|
||
label="اجرای راهاندازی پایگاه داده"
|
||
icon="pi pi-play"
|
||
class="font-bold"
|
||
:loading="isSeeding"
|
||
@click="confirmSeed"
|
||
/>
|
||
<span class="text-xs text-muted line-height-3">
|
||
این عملیات فقط یکبار قابل اجرا است.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</TabPanel>
|
||
|
||
<TabPanel value="4">
|
||
<div class="p-4 sm:p-5">
|
||
<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>،
|
||
<code dir="ltr">raw-data/taksa-tir-1405-import.json</code>
|
||
و <code dir="ltr">raw-data/taksa-tir-1405-payments-import.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>جلسه جدید: {{ importResult.stats?.sessionsCreated ?? 0 }} — بازاستفاده: {{ importResult.stats?.sessionsReused ?? 0 }}</li>
|
||
<li>حضور و غیاب: {{ importResult.stats?.attendanceRecords ?? 0 }} — بدون کاربر: {{ importResult.stats?.attendanceSkipped ?? 0 }}</li>
|
||
<li>پرداخت جدید: {{ importResult.stats?.paymentsCreated ?? 0 }} — بهروزرسانی: {{ importResult.stats?.paymentsUpdated ?? 0 }} — بازاستفاده: {{ importResult.stats?.paymentsReused ?? 0 }}</li>
|
||
<li>تراکنش: {{ importResult.stats?.transactionsCreated ?? 0 }}</li>
|
||
<li v-if="importResult.warnings?.length">هشدارها: {{ importResult.warnings.length }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TabPanel>
|
||
</TabPanels>
|
||
</Tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import { useConfirm } from 'primevue/useconfirm';
|
||
import Button from 'primevue/button';
|
||
import InputSwitch from 'primevue/toggleswitch';
|
||
import InputText from 'primevue/inputtext';
|
||
import Select from 'primevue/select';
|
||
import Tag from 'primevue/tag';
|
||
import Tabs from 'primevue/tabs';
|
||
import TabList from 'primevue/tablist';
|
||
import Tab from 'primevue/tab';
|
||
import TabPanels from 'primevue/tabpanels';
|
||
import TabPanel from 'primevue/tabpanel';
|
||
import PageHeader from '@/components/common/PageHeader.vue';
|
||
import { seedApi } from '@/api/seedApi';
|
||
import { settingsApi } from '@/api/settingsApi';
|
||
import { useToast } from '@/composables/useToast';
|
||
|
||
const confirm = useConfirm();
|
||
const { showSuccess, showError } = useToast();
|
||
|
||
const isSettingsLoading = ref(true);
|
||
const isSavingSettings = ref(false);
|
||
const isSavingMessaging = ref(false);
|
||
const isSavingNotifications = ref(false);
|
||
const smsTemplates = ref([]);
|
||
const templateForm = ref({});
|
||
const notificationSettings = ref([]);
|
||
const notificationForm = 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 notificationChannelDefs = [
|
||
{ key: 'sms', label: 'پیامک', icon: 'pi pi-mobile' },
|
||
{ key: 'email', label: 'ایمیل', icon: 'pi pi-envelope' },
|
||
{ key: 'bot', label: 'ربات', icon: 'pi pi-comments' }
|
||
];
|
||
|
||
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 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 TEMPLATE_SLOT_DEFS = {
|
||
accountCreated: [
|
||
{ value: 'username', label: 'نام کاربری', defaultName: 'user' },
|
||
{ value: 'password', label: 'رمز عبور', defaultName: 'password' },
|
||
{ value: 'fullName', label: 'نام و نام خانوادگی', defaultName: 'name' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||
{ value: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
|
||
],
|
||
passwordReset: [
|
||
{ value: 'username', label: 'نام کاربری', defaultName: 'user' },
|
||
{ value: 'password', label: 'رمز عبور', defaultName: 'password' },
|
||
{ value: 'fullName', label: 'نام کاربر', defaultName: 'name' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||
{ value: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
|
||
],
|
||
classRegistered: [
|
||
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
|
||
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
|
||
],
|
||
classReminder: [
|
||
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
|
||
{ value: 'time', label: 'ساعت برگزاری', defaultName: 'time' },
|
||
{ value: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' },
|
||
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
|
||
{ value: 'place', label: 'مکان برگزاری', defaultName: 'place' },
|
||
{ value: 'fullName', label: 'نام کاربر', defaultName: 'fullName' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
|
||
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
|
||
],
|
||
invoiceCreated: [
|
||
{ value: 'amount', label: 'مبلغ صورتحساب (تومان)', defaultName: 'PAYMENT_PRICE' },
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||
{ value: 'course', label: 'نام دوره / کلاس', defaultName: 'COURSE' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
|
||
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
|
||
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
|
||
],
|
||
paymentStatusChanged: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'status', label: 'وضعیت', defaultName: 'status' },
|
||
{ value: 'amount', label: 'مبلغ', defaultName: 'amount' },
|
||
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
|
||
{ value: 'course', label: 'نام دوره', defaultName: 'course' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
paymentReminder: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'amount', label: 'مبلغ باقیمانده', defaultName: 'amount' },
|
||
{ value: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' },
|
||
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
|
||
{ value: 'course', label: 'نام دوره', defaultName: 'course' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
transactionRecorded: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'amount', label: 'مبلغ', defaultName: 'amount' },
|
||
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
|
||
{ value: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' },
|
||
{ value: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
sessionCancelled: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
|
||
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
|
||
{ value: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
|
||
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
sessionHolding: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
||
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
|
||
{ value: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||
{ value: 'place', label: 'مکان', defaultName: 'place' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
classRequestApproved: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
classRequestRejected: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'reason', label: 'دلیل', defaultName: 'reason' },
|
||
{ value: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
pendingRegistration: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||
{ value: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
],
|
||
certificateIssued: [
|
||
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
|
||
{ value: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' },
|
||
{ value: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
|
||
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||
]
|
||
};
|
||
|
||
const getAvailableSlots = (templateKey) => {
|
||
return TEMPLATE_SLOT_DEFS[templateKey] || [];
|
||
};
|
||
|
||
const getSlotDefaultName = (templateKey, slotKey) => {
|
||
const slots = getAvailableSlots(templateKey);
|
||
const found = slots.find((s) => s.value === slotKey);
|
||
return found?.defaultName || '';
|
||
};
|
||
|
||
let varIdCounter = 0;
|
||
const nextVarId = () => `var_${++varIdCounter}_${Date.now()}`;
|
||
|
||
const lockedAtLabel = computed(() => {
|
||
if (!lockedAt.value) return '';
|
||
const date = new Date(lockedAt.value);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
return `قفل شده در ${date.toLocaleString('fa-IR')}`;
|
||
});
|
||
|
||
const addVariable = (templateKey) => {
|
||
const form = templateForm.value[templateKey];
|
||
if (!form) return;
|
||
if (!Array.isArray(form.variables)) {
|
||
form.variables = [];
|
||
}
|
||
const availableSlots = getAvailableSlots(templateKey);
|
||
|
||
const existingSlots = new Set(form.variables.map((v) => v.slot));
|
||
const unusedSlot = availableSlots.find((s) => !existingSlots.has(s.value));
|
||
const selectedSlot = unusedSlot || availableSlots[0] || { value: 'amount', defaultName: 'PAYMENT_PRICE' };
|
||
|
||
form.variables.push({
|
||
id: nextVarId(),
|
||
slot: selectedSlot.value,
|
||
name: ''
|
||
});
|
||
};
|
||
|
||
const removeVariable = (templateKey, index) => {
|
||
const form = templateForm.value[templateKey];
|
||
if (form && form.variables) {
|
||
form.variables.splice(index, 1);
|
||
}
|
||
};
|
||
|
||
const applyStatus = (payload) => {
|
||
const status = payload?.data?.data || payload?.data || payload || {};
|
||
isLocked.value = Boolean(status.locked);
|
||
lockedAt.value = status.lockedAt || null;
|
||
};
|
||
|
||
const loadStatus = async () => {
|
||
isStatusLoading.value = true;
|
||
try {
|
||
const response = await seedApi.getStatus();
|
||
applyStatus(response);
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isStatusLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const runSeed = async () => {
|
||
isSeeding.value = true;
|
||
try {
|
||
const response = await seedApi.runSeed();
|
||
applyStatus({ locked: true, lockedAt: new Date().toISOString(), ...(response?.data || response) });
|
||
isLocked.value = true;
|
||
showSuccess('راهاندازی پایگاه داده با موفقیت انجام شد.');
|
||
} catch (err) {
|
||
showError(err);
|
||
await loadStatus();
|
||
} finally {
|
||
isSeeding.value = false;
|
||
}
|
||
};
|
||
|
||
const confirmSeed = () => {
|
||
confirm.require({
|
||
header: 'راهاندازی پایگاه داده',
|
||
message: 'نقشهای سیستمی و حساب مدیر ارشد ایجاد یا بهروز میشوند. این دکمه پس از اجرا دیگر نمایش داده نمیشود. ادامه میدهید؟',
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'اجرا',
|
||
rejectLabel: 'انصراف',
|
||
acceptClass: 'p-button-primary',
|
||
accept: () => {
|
||
runSeed();
|
||
}
|
||
});
|
||
};
|
||
|
||
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();
|
||
}
|
||
});
|
||
};
|
||
|
||
const emptyFormEntry = (template) => {
|
||
const slots = getAvailableSlots(template?.key);
|
||
let vars = [];
|
||
const incomingVars = Array.isArray(template?.variables)
|
||
? template.variables
|
||
: Object.values(template?.variables || {});
|
||
if (incomingVars.length > 0) {
|
||
vars = incomingVars.map((variable) => ({
|
||
id: nextVarId(),
|
||
slot: variable.slot || slots[0]?.value || 'amount',
|
||
name: variable.name || getSlotDefaultName(template?.key, variable.slot) || ''
|
||
}));
|
||
} else if (!template?.variables) {
|
||
vars = slots.map((s) => ({
|
||
id: nextVarId(),
|
||
slot: s.value,
|
||
name: s.defaultName
|
||
}));
|
||
}
|
||
|
||
return {
|
||
enabled: template?.enabled !== false,
|
||
templateId: template?.templateId || '',
|
||
variables: vars
|
||
};
|
||
};
|
||
|
||
const applySmsTemplates = (payload) => {
|
||
const data = payload?.data?.data || payload?.data || payload || {};
|
||
const templates = Array.isArray(data.smsTemplates) ? data.smsTemplates : [];
|
||
smsTemplates.value = templates;
|
||
const newForm = {};
|
||
templates.forEach((template) => {
|
||
newForm[template.key] = emptyFormEntry(template);
|
||
});
|
||
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 applyNotificationSettings = (payload) => {
|
||
const data = payload?.data?.data || payload?.data || payload || {};
|
||
const groups = Array.isArray(data.notificationSettings) ? data.notificationSettings : [];
|
||
notificationSettings.value = groups;
|
||
|
||
const form = {};
|
||
groups.forEach((group) => {
|
||
(group.actions || []).forEach((action) => {
|
||
form[action.key] = {
|
||
sms: action.channels?.sms !== false,
|
||
email: action.channels?.email === true,
|
||
bot: action.channels?.bot === true
|
||
};
|
||
});
|
||
});
|
||
notificationForm.value = form;
|
||
};
|
||
|
||
const applySettings = (payload) => {
|
||
applySmsTemplates(payload);
|
||
applyMessaging(payload);
|
||
applyNotificationSettings(payload);
|
||
};
|
||
|
||
const loadSettings = async () => {
|
||
isSettingsLoading.value = true;
|
||
try {
|
||
const response = await settingsApi.get();
|
||
applySettings(response);
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isSettingsLoading.value = false;
|
||
}
|
||
};
|
||
|
||
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 saveNotificationSettings = async () => {
|
||
isSavingNotifications.value = true;
|
||
try {
|
||
const payload = {};
|
||
notificationSettings.value.forEach((group) => {
|
||
(group.actions || []).forEach((action) => {
|
||
const channels = notificationForm.value[action.key] || {};
|
||
payload[action.key] = {
|
||
sms: Boolean(channels.sms),
|
||
email: Boolean(channels.email),
|
||
bot: Boolean(channels.bot)
|
||
};
|
||
});
|
||
});
|
||
const response = await settingsApi.save({ notificationSettings: payload });
|
||
applySettings(response);
|
||
showSuccess('تنظیمات اطلاعرسانی با موفقیت ذخیره شد.');
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isSavingNotifications.value = false;
|
||
}
|
||
};
|
||
|
||
const saveSmsTemplates = async () => {
|
||
isSavingSettings.value = true;
|
||
try {
|
||
const smsTemplatesPayload = {};
|
||
smsTemplates.value.forEach((template) => {
|
||
const entry = templateForm.value[template.key] || emptyFormEntry(template);
|
||
const rawVariables = Array.isArray(entry.variables)
|
||
? entry.variables
|
||
: Object.values(entry.variables || {});
|
||
smsTemplatesPayload[template.key] = {
|
||
enabled: Boolean(entry.enabled),
|
||
templateId: String(entry.templateId || '').trim(),
|
||
variables: rawVariables
|
||
.filter((v) => v && v.slot)
|
||
.map((v) => {
|
||
const raw = String(v.name || '').trim().replace(/^#+|#+$/g, '');
|
||
const fallback = getSlotDefaultName(template.key, v.slot) || v.slot;
|
||
return {
|
||
slot: v.slot,
|
||
name: raw || fallback
|
||
};
|
||
})
|
||
};
|
||
});
|
||
const response = await settingsApi.save({ smsTemplates: smsTemplatesPayload });
|
||
applySettings(response);
|
||
showSuccess('قالبهای پیامک با موفقیت ذخیره شدند.');
|
||
} catch (err) {
|
||
showError(err);
|
||
} finally {
|
||
isSavingSettings.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadStatus();
|
||
loadSettings();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
</style>
|