Files
gameno-dashboard/src/views/settings/SettingsView.vue
T

953 lines
41 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /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>