feat: add employee timings view, notification templates view, and professor sms preview
This commit is contained in:
+3
-1
@@ -12,5 +12,7 @@ export const classApi = {
|
|||||||
registerUsers: (classId, userIds, notify) =>
|
registerUsers: (classId, userIds, notify) =>
|
||||||
axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds, notify }),
|
axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds, notify }),
|
||||||
removeUser: (classId, userId) =>
|
removeUser: (classId, userId) =>
|
||||||
axiosInstance.delete(`/classes/admin/${classId}/students/${userId}`)
|
axiosInstance.delete(`/classes/admin/${classId}/students/${userId}`),
|
||||||
|
sendPlanToProfessor: (classId) =>
|
||||||
|
axiosInstance.post(`/classes/admin/${classId}/send-plan-professor`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// /src/api/employeeTimingApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const employeeTimingApi = {
|
||||||
|
getAll: (params) => axiosInstance.get('/employee-timings/admin/get-all', { params }),
|
||||||
|
getSummary: (params) => axiosInstance.get('/employee-timings/admin/summary', { params }),
|
||||||
|
getOne: (id) => axiosInstance.get(`/employee-timings/admin/get-one/${id}`),
|
||||||
|
create: (data) => axiosInstance.post('/employee-timings/admin/create', data),
|
||||||
|
update: (id, data) => axiosInstance.put(`/employee-timings/admin/update/${id}`, data),
|
||||||
|
delete: (id) => axiosInstance.delete(`/employee-timings/admin/delete/${id}`)
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- /src/components/common/SmsPreviewConfirmDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
modal
|
||||||
|
:header="title || 'پیشنمایش و تأیید ارسال پیامک'"
|
||||||
|
:style="{ width: '560px', maxWidth: '95vw' }"
|
||||||
|
:closable="!loading"
|
||||||
|
@update:visible="onUpdateVisible"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3 py-1">
|
||||||
|
<!-- Recipient Header Card -->
|
||||||
|
<div class="flex align-items-center justify-content-between p-3 border-round-xl surface-ground border-1 border-color">
|
||||||
|
<div class="flex align-items-center gap-3">
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg flex align-items-center justify-content-center bg-primary-light text-primary font-bold">
|
||||||
|
<i class="pi pi-user text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block">گیرنده پیامک</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ recipientName || 'گیرنده' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="recipientPhone" class="flex align-items-center gap-2" dir="ltr">
|
||||||
|
<i class="pi pi-phone text-muted text-xs"></i>
|
||||||
|
<span class="font-mono text-sm font-semibold text-color">{{ recipientPhone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exact SMS Message Preview Box -->
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="text-xs font-bold text-muted flex align-items-center gap-1">
|
||||||
|
<i class="pi pi-envelope text-primary"></i>
|
||||||
|
متن دقیق ارسالی (با جایگذاری متغیرها):
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="messageText"
|
||||||
|
icon="pi pi-copy"
|
||||||
|
label="کپی متن"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="copyText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-bubble p-4 border-round-xl surface-card border-1 border-primary-light shadow-1 line-height-3 text-sm text-color white-space-pre-wrap position-relative font-medium">
|
||||||
|
{{ messageText || 'متنی برای نمایش وجود ندارد.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center justify-content-between px-1 text-xs text-muted">
|
||||||
|
<span>تعداد کاراکتر: <strong class="text-color">{{ toPersianDigits(charCount) }}</strong></span>
|
||||||
|
<span>تعداد بخش پیامک: <strong class="text-color">{{ toPersianDigits(partCount) }}</strong> صفحه</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informational Alert -->
|
||||||
|
<div class="p-3 border-round-lg surface-100 border-1 border-dashed border-color text-xs text-muted flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-info-circle text-primary text-base flex-shrink-0"></i>
|
||||||
|
<span>این پیامک بلافاصله از طریق درگاه پیامک ارسال شده و متن دقیق آن در بخش اطلاعیهها ثبت خواهد شد.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
label="انصراف"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="confirmLabel || 'ارسال پیامک'"
|
||||||
|
icon="pi pi-send"
|
||||||
|
severity="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="onConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'پیشنمایش و تأیید ارسال پیامک'
|
||||||
|
},
|
||||||
|
recipientName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
recipientPhone: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'ارسال پیامک'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'confirm', 'cancel']);
|
||||||
|
const { showSuccess } = useToast();
|
||||||
|
|
||||||
|
const charCount = computed(() => {
|
||||||
|
return (props.messageText || '').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const partCount = computed(() => {
|
||||||
|
const len = charCount.value;
|
||||||
|
if (len === 0) return 0;
|
||||||
|
if (len <= 70) return 1;
|
||||||
|
return Math.ceil(len / 67);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onUpdateVisible = (val) => {
|
||||||
|
emit('update:visible', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async () => {
|
||||||
|
if (!props.messageText) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.messageText);
|
||||||
|
showSuccess('متن پیامک کپی شد.');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sms-bubble {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--primary-rgb, 59, 130, 246), 0.04) 0%, rgba(var(--primary-rgb, 59, 130, 246), 0.08) 100%);
|
||||||
|
border-right: 4px solid var(--primary-color, #3b82f6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -142,6 +142,7 @@ const menuGroups = computed(() => {
|
|||||||
key: 'system',
|
key: 'system',
|
||||||
label: 'مدیریت سیستم',
|
label: 'مدیریت سیستم',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'ورود و خروج کارمندان', icon: 'pi pi-clock', to: '/employee-timings', permission: PERMISSIONS.EMPLOYEE_TIMINGS_READ },
|
||||||
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ },
|
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ },
|
||||||
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
||||||
]
|
]
|
||||||
@@ -166,6 +167,7 @@ const menuGroups = computed(() => {
|
|||||||
|
|
||||||
if (permissionStore.roleName === 'SuperAdmin') {
|
if (permissionStore.roleName === 'SuperAdmin') {
|
||||||
const systemGroup = groups.find((g) => g.key === 'system');
|
const systemGroup = groups.find((g) => g.key === 'system');
|
||||||
|
systemGroup.items.push({ label: 'قالبهای اعلان', icon: 'pi pi-send', to: '/notification-templates' });
|
||||||
systemGroup.items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' });
|
systemGroup.items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,12 @@ export const PERMISSIONS = {
|
|||||||
WAITLIST_CREATE: 'waitlist:create',
|
WAITLIST_CREATE: 'waitlist:create',
|
||||||
WAITLIST_READ: 'waitlist:read',
|
WAITLIST_READ: 'waitlist:read',
|
||||||
WAITLIST_UPDATE: 'waitlist:update',
|
WAITLIST_UPDATE: 'waitlist:update',
|
||||||
WAITLIST_DELETE: 'waitlist:delete'
|
WAITLIST_DELETE: 'waitlist:delete',
|
||||||
|
|
||||||
|
EMPLOYEE_TIMINGS_CREATE: 'employee_timings:create',
|
||||||
|
EMPLOYEE_TIMINGS_READ: 'employee_timings:read',
|
||||||
|
EMPLOYEE_TIMINGS_UPDATE: 'employee_timings:update',
|
||||||
|
EMPLOYEE_TIMINGS_DELETE: 'employee_timings:delete'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERMISSION_GROUPS = [
|
export const PERMISSION_GROUPS = [
|
||||||
@@ -106,6 +111,17 @@ export const PERMISSION_GROUPS = [
|
|||||||
{ key: PERMISSIONS.USERS_ENROLL, label: 'ثبتنام کاربر در دوره' }
|
{ key: PERMISSIONS.USERS_ENROLL, label: 'ثبتنام کاربر در دوره' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'employee_timings',
|
||||||
|
label: 'تردد و ساعت کارمندان',
|
||||||
|
icon: 'pi pi-clock',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_CREATE, label: 'ثبت تردد جدید' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_READ, label: 'مشاهده تردد کارمندان' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE, label: 'ویرایش تردد' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_DELETE, label: 'حذف تردد' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'professors',
|
key: 'professors',
|
||||||
label: 'مدیریت اساتید',
|
label: 'مدیریت اساتید',
|
||||||
|
|||||||
@@ -156,5 +156,15 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"title": "تنظیمات سیستم",
|
"title": "تنظیمات سیستم",
|
||||||
"subtitle": "عملیات راهاندازی و پیکربندی اولیه پایگاه داده"
|
"subtitle": "عملیات راهاندازی و پیکربندی اولیه پایگاه داده"
|
||||||
|
},
|
||||||
|
"employeeTimings": {
|
||||||
|
"title": "ورود و خروج کارمندان",
|
||||||
|
"subtitle": "ثبت، ویرایش و مدیریت زمان تردد و ساعت کاری کارمندان",
|
||||||
|
"addTiming": "ثبت تردد جدید",
|
||||||
|
"editTiming": "ویرایش رکورد تردد"
|
||||||
|
},
|
||||||
|
"notificationTemplates": {
|
||||||
|
"title": "قالبهای اعلان",
|
||||||
|
"subtitle": "مدیریت متن، شناسهها و متغیرهای قالبهای پیامک، ایمیل و بازوی بله"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,22 @@ export const routes = [
|
|||||||
meta: { permission: 'logs:read' }
|
meta: { permission: 'logs:read' }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Employee timings
|
||||||
|
{
|
||||||
|
path: 'employee-timings',
|
||||||
|
name: 'EmployeeTimingList',
|
||||||
|
component: () => import('@/views/employeeTimings/EmployeeTimingListView.vue'),
|
||||||
|
meta: { title: 'ورود و خروج کارمندان', permission: 'employee_timings:read' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notification templates
|
||||||
|
{
|
||||||
|
path: 'notification-templates',
|
||||||
|
name: 'NotificationTemplates',
|
||||||
|
component: () => import('@/views/notifications/NotificationTemplatesView.vue'),
|
||||||
|
meta: { title: 'قالبهای اعلان', superAdminOnly: true }
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
|||||||
@@ -5,12 +5,23 @@
|
|||||||
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
|
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
|
||||||
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
|
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
|
||||||
>
|
>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="isEditMode && form.professor"
|
||||||
|
label="ارسال جزئیات کلاس به استاد"
|
||||||
|
icon="pi pi-send"
|
||||||
|
severity="info"
|
||||||
|
outlined
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
@click="openProfessorSmsDialog"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="انصراف"
|
label="انصراف"
|
||||||
text
|
text
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||||
@@ -304,12 +315,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
v-model="removeDialogVisible"
|
v-model:visible="removeDialogVisible"
|
||||||
title="حذف از کلاس"
|
title="حذف دانشجو از کلاس"
|
||||||
:message="removeDialogMessage"
|
:message="removeDialogMessage"
|
||||||
:loading="!!removingUserId"
|
:loading="!!removingUserId"
|
||||||
@confirm="handleRemoveStudent"
|
@confirm="handleRemoveStudent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SmsPreviewConfirmDialog
|
||||||
|
v-model:visible="isProfessorSmsDialogVisible"
|
||||||
|
title="ارسال جزئیات برنامه کلاس به استاد"
|
||||||
|
:recipientName="selectedProfessor?.name || 'استاد'"
|
||||||
|
:recipientPhone="selectedProfessor?.phoneNumber || ''"
|
||||||
|
:messageText="professorSmsText"
|
||||||
|
:loading="isSendingProfessorSms"
|
||||||
|
confirmLabel="ارسال پیامک به استاد"
|
||||||
|
@confirm="sendProfessorClassPlanSms"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -329,6 +351,7 @@ import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
|||||||
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import SmsPreviewConfirmDialog from '@/components/common/SmsPreviewConfirmDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import InputNumber from 'primevue/inputnumber';
|
import InputNumber from 'primevue/inputnumber';
|
||||||
@@ -643,5 +666,54 @@ watch(() => form.course, (courseId) => {
|
|||||||
selectedCourseHoursPerSection.value = course?.hoursPerSection ?? null;
|
selectedCourseHoursPerSection.value = course?.hoursPerSection ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isProfessorSmsDialogVisible = ref(false);
|
||||||
|
const isSendingProfessorSms = ref(false);
|
||||||
|
const professorSmsText = ref('');
|
||||||
|
|
||||||
|
const selectedProfessor = computed(() => {
|
||||||
|
if (!form.professor) return null;
|
||||||
|
return professors.value.find((p) => String(p._id) === String(form.professor)) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openProfessorSmsDialog = () => {
|
||||||
|
const prof = selectedProfessor.value;
|
||||||
|
if (!prof) {
|
||||||
|
showError('استاد این کلاس مشخص نشده است.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profName = `${prof.name || ''} ${prof.surname || ''}`.trim() || 'استاد';
|
||||||
|
const className = form.name || 'کلاس';
|
||||||
|
const selectedDayLabels = (form.days || [])
|
||||||
|
.map((d) => weekdays.find((w) => w.value === d)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('، ') || 'طبق هماهنگی';
|
||||||
|
const classTimes = (form.startTime && form.endTime)
|
||||||
|
? `${form.startTime} الی ${form.endTime}`
|
||||||
|
: (form.startTime || form.endTime || 'طبق هماهنگی');
|
||||||
|
const startDateStr = form.startDate ? toPersianDigits(form.startDate) : '—';
|
||||||
|
const endDateStr = calculatedEndDateDisplay.value !== '—' ? calculatedEndDateDisplay.value : (form.endDate ? toPersianDigits(form.endDate) : '—');
|
||||||
|
|
||||||
|
professorSmsText.value = `با سلام و وقت بخیر، استاد ${profName}،
|
||||||
|
برنامه کلاس ${className} شما به شرح زیر می باشد:
|
||||||
|
${selectedDayLabels}، ${classTimes}
|
||||||
|
از ${startDateStr} الی ${endDateStr}`;
|
||||||
|
|
||||||
|
isProfessorSmsDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendProfessorClassPlanSms = async () => {
|
||||||
|
if (!classId) return;
|
||||||
|
isSendingProfessorSms.value = true;
|
||||||
|
try {
|
||||||
|
await classApi.sendPlanToProfessor(classId);
|
||||||
|
showSuccess('برنامه کلاس با موفقیت برای استاد پیامک شد.');
|
||||||
|
isProfessorSmsDialogVisible.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSendingProfessorSms.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(fetchData);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
<!-- /src/views/employeeTimings/EmployeeTimingListView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="employee-timing-list-view">
|
||||||
|
<PageHeader
|
||||||
|
title="ورود و خروج کارمندان"
|
||||||
|
subtitle="ثبت، ویرایش و مدیریت زمان تردد، ساعت کاری و یادداشتهای کارمندان"
|
||||||
|
>
|
||||||
|
<PermissionGate permission="employee_timings:create">
|
||||||
|
<Button
|
||||||
|
label="ثبت تردد جدید"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Top Summary KPI Cards -->
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">کل رکوردهای تردد</span>
|
||||||
|
<span class="text-3xl font-bold text-color">{{ toPersianDigits(summary.total || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-primary-light text-primary">
|
||||||
|
<i class="pi pi-clock text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">ترددهای کامل</span>
|
||||||
|
<span class="text-3xl font-bold text-green-500">{{ toPersianDigits(summary.complete || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-green-50 text-green-600">
|
||||||
|
<i class="pi pi-check-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div
|
||||||
|
class="surface-card p-4 border-round-xl border-1 shadow-sm h-full flex align-items-center justify-content-between cursor-pointer transition-colors"
|
||||||
|
:class="statusFilter === 'missing_exit' ? 'border-orange-500 bg-orange-50' : 'border-color'"
|
||||||
|
@click="setStatusQuickFilter('missing_exit')"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">فاقد زمان خروج (هشدار)</span>
|
||||||
|
<span class="text-3xl font-bold text-orange-500">{{ toPersianDigits(summary.missingExit || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-orange-100 text-orange-600">
|
||||||
|
<i class="pi pi-exclamation-triangle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div
|
||||||
|
class="surface-card p-4 border-round-xl border-1 shadow-sm h-full flex align-items-center justify-content-between cursor-pointer transition-colors"
|
||||||
|
:class="statusFilter === 'missing_entry' ? 'border-red-500 bg-red-50' : 'border-color'"
|
||||||
|
@click="setStatusQuickFilter('missing_entry')"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">فاقد زمان ورود (هشدار)</span>
|
||||||
|
<span class="text-3xl font-bold text-red-500">{{ toPersianDigits(summary.missingEntry || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-red-100 text-red-600">
|
||||||
|
<i class="pi pi-exclamation-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table & Filter Toolbar -->
|
||||||
|
<DataTableWrapper
|
||||||
|
:items="items"
|
||||||
|
:totalCount="totalCount"
|
||||||
|
:page="queryParams.page"
|
||||||
|
:limit="queryParams.limit"
|
||||||
|
:sortBy="queryParams.sortBy"
|
||||||
|
:sortOrder="queryParams.sortOrder"
|
||||||
|
:loading="isLoading"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@sort-change="onSort"
|
||||||
|
@search-change="onSearch"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="flex align-items-center justify-content-between flex-wrap gap-3 w-full">
|
||||||
|
<!-- Status filter -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<SelectButton
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="statusOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="text-xs"
|
||||||
|
@change="onStatusFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Dropdown filter & Date pickers -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="همه کارمندان / کاربران"
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
class="w-16rem text-sm"
|
||||||
|
@change="onUserFilterChange"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-xs">{{ option.name }}</span>
|
||||||
|
<span class="text-muted text-xs" dir="ltr">{{ option.phoneNumber || option.nationalIdCode || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
v-model="startDateFilter"
|
||||||
|
placeholder="از تاریخ"
|
||||||
|
class="w-10rem text-sm"
|
||||||
|
@update:modelValue="onDateFilterChange"
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
v-model="endDateFilter"
|
||||||
|
placeholder="تا تاریخ"
|
||||||
|
class="w-10rem text-sm"
|
||||||
|
@update:modelValue="onDateFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Employee info -->
|
||||||
|
<Column field="user" header="کارمند / کاربر">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<div class="w-2rem h-2rem border-round-circle flex align-items-center justify-content-center bg-primary-light text-primary font-bold text-xs">
|
||||||
|
{{ (data.user?.name || 'ک').charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-bold text-color block text-sm">{{ data.user?.name || '—' }}</span>
|
||||||
|
<div class="flex align-items-center gap-2 text-xs text-muted">
|
||||||
|
<span v-if="data.user?.nationalIdCode" dir="ltr">کد ملی: {{ data.user.nationalIdCode }}</span>
|
||||||
|
<span v-if="data.user?.phoneNumber" dir="ltr">{{ data.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<Column field="date" header="تاریخ" sortable sortField="date">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-semibold text-color">{{ formatJalali(data.date) }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ formatJalaliDayName(data.date) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Entry time -->
|
||||||
|
<Column field="entryTime" header="زمان ورود">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.entryTime" class="font-mono text-sm font-bold text-green-600 bg-green-50 px-2 py-1 border-round" dir="ltr">
|
||||||
|
{{ toPersianDigits(data.entryTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-orange-500 font-semibold bg-orange-50 px-2 py-1 border-round">
|
||||||
|
ثبت نشده
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Exit time -->
|
||||||
|
<Column field="exitTime" header="زمان خروج">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.exitTime" class="font-mono text-sm font-bold text-blue-600 bg-blue-50 px-2 py-1 border-round" dir="ltr">
|
||||||
|
{{ toPersianDigits(data.exitTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-orange-500 font-semibold bg-orange-50 px-2 py-1 border-round">
|
||||||
|
ثبت نشده
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<Column field="durationFormatted" header="مدت کارکرد">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs font-semibold text-color">
|
||||||
|
{{ data.durationFormatted ? toPersianDigits(data.durationFormatted) : '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Status & Warnings -->
|
||||||
|
<Column field="status" header="وضعیت / هشدار">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.status === 'complete'"
|
||||||
|
value="کامل"
|
||||||
|
severity="success"
|
||||||
|
icon="pi pi-check-circle"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'missing_exit'"
|
||||||
|
value="فاقد زمان خروج"
|
||||||
|
severity="warn"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
v-tooltip.top="'زمان ورود ثبت شده ولی خروج ثبت نشده است'"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'missing_entry'"
|
||||||
|
value="فاقد زمان ورود"
|
||||||
|
severity="danger"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
v-tooltip.top="'زمان خروج ثبت شده ولی ورود ثبت نشده است'"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else
|
||||||
|
value="ناقص (بدون ورود و خروج)"
|
||||||
|
severity="danger"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Note -->
|
||||||
|
<Column field="note" header="یادداشت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span
|
||||||
|
v-if="data.note"
|
||||||
|
class="text-xs text-muted line-height-2 block max-w-16rem overflow-hidden text-overflow-ellipsis white-space-nowrap"
|
||||||
|
:title="data.note"
|
||||||
|
>
|
||||||
|
{{ data.note }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted text-xs">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<Column header="عملیات" style="width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<PermissionGate permission="employee_timings:update">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
title="ویرایش"
|
||||||
|
@click="openEditDialog(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="employee_timings:delete">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
title="حذف"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTableWrapper>
|
||||||
|
|
||||||
|
<!-- Create / Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="isFormDialogVisible"
|
||||||
|
modal
|
||||||
|
:header="editingId ? 'ویرایش رکورد تردد کارمند' : 'ثبت رکورد جدید تردد کارمند'"
|
||||||
|
:style="{ width: '520px', maxWidth: '95vw' }"
|
||||||
|
>
|
||||||
|
<form @submit.prevent="saveForm" class="flex flex-column gap-3 py-1">
|
||||||
|
<!-- User selector -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">کارمند / کاربر *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.user"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="انتخاب کارمند"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-xs">{{ option.name }}</span>
|
||||||
|
<span class="text-muted text-xs" dir="ltr">{{ option.phoneNumber || option.nationalIdCode || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date picker -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">تاریخ تردد *</label>
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.date"
|
||||||
|
class="w-full text-sm"
|
||||||
|
:placeholder="getTodayJalali()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry and Exit times with quick set now buttons -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label class="font-semibold text-sm">زمان ورود</label>
|
||||||
|
<Button
|
||||||
|
label="اکنون"
|
||||||
|
icon="pi pi-clock"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="setNow('entryTime')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="form.entryTime"
|
||||||
|
placeholder="08:30"
|
||||||
|
class="w-full text-sm font-mono text-center"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label class="font-semibold text-sm">زمان خروج</label>
|
||||||
|
<Button
|
||||||
|
label="اکنون"
|
||||||
|
icon="pi pi-clock"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="setNow('exitTime')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="form.exitTime"
|
||||||
|
placeholder="17:00"
|
||||||
|
class="w-full text-sm font-mono text-center"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incomplete Warning Callout in Modal -->
|
||||||
|
<div
|
||||||
|
v-if="formWarningMessage"
|
||||||
|
class="p-3 border-round-lg bg-orange-50 border-1 border-orange-200 text-orange-700 text-xs flex align-items-start gap-2"
|
||||||
|
>
|
||||||
|
<i class="pi pi-exclamation-triangle text-base mt-1 flex-shrink-0"></i>
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<span class="font-bold">هشدار عدم تکمیل رکورد:</span>
|
||||||
|
<span>{{ formWarningMessage }}. این رکورد با وضعیت هشدار ذخیره میشود.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Textarea -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">یادداشت و توضیحات</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.note"
|
||||||
|
rows="3"
|
||||||
|
class="w-full text-sm line-height-3 surface-card"
|
||||||
|
autoResize
|
||||||
|
placeholder="یادداشت، مرخصی ساعتی، مأموریت، تأخیر یا توضیحات تردد..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
label="انصراف"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="isFormDialogVisible = false"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="editingId ? 'بهروزرسانی تردد' : 'ثبت تردد'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="saveForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
import { employeeTimingApi } from '@/api/employeeTimingApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { toPersianDigits, formatJalali, toJalaliPickerValue, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
|
||||||
|
const statusFilter = ref('all');
|
||||||
|
const selectedUserId = ref(null);
|
||||||
|
const startDateFilter = ref(null);
|
||||||
|
const endDateFilter = ref(null);
|
||||||
|
|
||||||
|
const summary = ref({
|
||||||
|
total: 0,
|
||||||
|
complete: 0,
|
||||||
|
incomplete: 0,
|
||||||
|
missingExit: 0,
|
||||||
|
missingEntry: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersList = ref([]);
|
||||||
|
const isFormDialogVisible = ref(false);
|
||||||
|
const editingId = ref(null);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user: null,
|
||||||
|
date: null,
|
||||||
|
entryTime: '',
|
||||||
|
exitTime: '',
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'همه', value: 'all' },
|
||||||
|
{ label: 'دارای هشدار (ناقص)', value: 'incomplete' },
|
||||||
|
{ label: 'فاقد زمان خروج', value: 'missing_exit' },
|
||||||
|
{ label: 'فاقد زمان ورود', value: 'missing_entry' },
|
||||||
|
{ label: 'کامل', value: 'complete' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchTimings = (params) => {
|
||||||
|
const query = { ...params };
|
||||||
|
if (statusFilter.value && statusFilter.value !== 'all') {
|
||||||
|
query.status = statusFilter.value;
|
||||||
|
}
|
||||||
|
if (selectedUserId.value) {
|
||||||
|
query.user = selectedUserId.value;
|
||||||
|
}
|
||||||
|
if (startDateFilter.value) {
|
||||||
|
query.startDate = toGregorianIso(startDateFilter.value) || startDateFilter.value;
|
||||||
|
}
|
||||||
|
if (endDateFilter.value) {
|
||||||
|
query.endDate = toGregorianIso(endDateFilter.value) || endDateFilter.value;
|
||||||
|
}
|
||||||
|
return employeeTimingApi.getAll(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
queryParams,
|
||||||
|
onPageChange,
|
||||||
|
onSort,
|
||||||
|
onSearch,
|
||||||
|
refresh
|
||||||
|
} = useDataTable(fetchTimings, {
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (selectedUserId.value) params.user = selectedUserId.value;
|
||||||
|
if (startDateFilter.value) params.startDate = toGregorianIso(startDateFilter.value) || startDateFilter.value;
|
||||||
|
if (endDateFilter.value) params.endDate = toGregorianIso(endDateFilter.value) || endDateFilter.value;
|
||||||
|
const response = await employeeTimingApi.getSummary(params);
|
||||||
|
const data = response?.data?.data || response?.data || {};
|
||||||
|
summary.value = {
|
||||||
|
total: data.total || 0,
|
||||||
|
complete: data.complete || 0,
|
||||||
|
incomplete: data.incomplete || 0,
|
||||||
|
missingExit: data.missingExit || 0,
|
||||||
|
missingEntry: data.missingEntry || 0
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// silent catch
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.getAll({ limit: 200 });
|
||||||
|
const data = response?.data?.data || response?.data || [];
|
||||||
|
usersList.value = Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
// silent catch
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStatusFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatusQuickFilter = (status) => {
|
||||||
|
if (statusFilter.value === status) {
|
||||||
|
statusFilter.value = 'all';
|
||||||
|
} else {
|
||||||
|
statusFilter.value = status;
|
||||||
|
}
|
||||||
|
onStatusFilterChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDateFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatJalaliDayName = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('fa-IR', { weekday: 'long' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formWarningMessage = computed(() => {
|
||||||
|
const hasEntry = Boolean(form.entryTime && form.entryTime.trim());
|
||||||
|
const hasExit = Boolean(form.exitTime && form.exitTime.trim());
|
||||||
|
if (hasEntry && !hasExit) return 'زمان خروج ثبت نشده است';
|
||||||
|
if (!hasEntry && hasExit) return 'زمان ورود ثبت نشده است';
|
||||||
|
if (!hasEntry && !hasExit) return 'زمان ورود و خروج هیچکدام ثبت نشده است';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setNow = (field) => {
|
||||||
|
const now = new Date();
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
form[field] = `${hh}:${mm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
editingId.value = null;
|
||||||
|
form.user = selectedUserId.value || (usersList.value[0]?._id || null);
|
||||||
|
form.date = getTodayJalali();
|
||||||
|
form.entryTime = '';
|
||||||
|
form.exitTime = '';
|
||||||
|
form.note = '';
|
||||||
|
isFormDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (record) => {
|
||||||
|
editingId.value = record._id || record.id;
|
||||||
|
form.user = record.user?._id || record.user;
|
||||||
|
form.date = toJalaliPickerValue(record.date) || record.date;
|
||||||
|
form.entryTime = record.entryTime || '';
|
||||||
|
form.exitTime = record.exitTime || '';
|
||||||
|
form.note = record.note || '';
|
||||||
|
isFormDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!form.user) {
|
||||||
|
showError('لطفاً کارمند را انتخاب کنید.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
user: form.user,
|
||||||
|
date: toGregorianIso(form.date) || form.date,
|
||||||
|
entryTime: form.entryTime,
|
||||||
|
exitTime: form.exitTime,
|
||||||
|
note: form.note
|
||||||
|
};
|
||||||
|
if (editingId.value) {
|
||||||
|
await employeeTimingApi.update(editingId.value, payload);
|
||||||
|
showSuccess('رکورد تردد با موفقیت بهروزرسانی شد.');
|
||||||
|
} else {
|
||||||
|
await employeeTimingApi.create(payload);
|
||||||
|
showSuccess('رکورد تردد جدید با موفقیت ثبت شد.');
|
||||||
|
}
|
||||||
|
isFormDialogVisible.value = false;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (record) => {
|
||||||
|
confirm.require({
|
||||||
|
header: 'حذف رکورد تردد',
|
||||||
|
message: `آیا از حذف رکورد تردد ${record.user?.name || 'این کارمند'} در تاریخ ${formatJalali(record.date)} اطمینان دارید؟`,
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'حذف',
|
||||||
|
rejectLabel: 'انصراف',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await employeeTimingApi.delete(record._id || record.id);
|
||||||
|
showSuccess('رکورد تردد با موفقیت حذف شد.');
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
loadSummary();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.employee-timing-list-view {
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<!-- /src/views/notifications/NotificationTemplatesView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="notification-templates-view w-full max-w-5xl mx-auto">
|
||||||
|
<PageHeader
|
||||||
|
title="قالبهای اعلان"
|
||||||
|
subtitle="مدیریت متن، شناسهها و متغیرهای قالبهای پیامک، ایمیل و بازوی بله"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
|
<TabList :scrollable="true">
|
||||||
|
<Tab value="0">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-mobile text-primary"></i>
|
||||||
|
<span>پیامک</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
<Tab value="1">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-envelope text-color-secondary"></i>
|
||||||
|
<span>ایمیل</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
<Tab value="2">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-comments text-color-secondary"></i>
|
||||||
|
<span>بازوی بله</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
<!-- Tab 0: SMS Templates -->
|
||||||
|
<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-send text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h2 class="text-lg font-bold text-color m-0 mb-1">قالبهای پیامک (sms.ir)</h2>
|
||||||
|
<p class="text-sm text-muted m-0 line-height-3">
|
||||||
|
شناسه قالب، متن و متغیرهای ارسالی پنل sms.ir را در این بخش تنظیم کنید. هر متغیر با فرمت <code dir="ltr">#NAME#</code> در متن قالب جایگذاری و هنگام ارسال جایگزین میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex align-items-center gap-2 text-muted text-sm py-4">
|
||||||
|
<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-xl 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]">
|
||||||
|
<!-- Template Header -->
|
||||||
|
<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-2.5rem h-2.5rem 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 text-base"></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
|
||||||
|
v-if="template.category || templateForm[template.key].category"
|
||||||
|
:value="template.category || templateForm[template.key].category"
|
||||||
|
severity="info"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Template ID Input -->
|
||||||
|
<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 font-mono"
|
||||||
|
dir="ltr"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="مثلاً: 720661"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template Text Area -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label
|
||||||
|
class="text-sm font-semibold text-color"
|
||||||
|
:for="`sms-text-${template.key}`"
|
||||||
|
>
|
||||||
|
متن قالب پیامک:
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
متغیرها را به فرمت <code dir="ltr" class="text-primary font-bold">#NAME#</code> بنویسید
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
:id="`sms-text-${template.key}`"
|
||||||
|
v-model="templateForm[template.key].text"
|
||||||
|
rows="3"
|
||||||
|
class="w-full text-sm line-height-3 surface-card"
|
||||||
|
autoResize
|
||||||
|
placeholder="متن کامل قالب پیامک را وارد کنید..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variables Management -->
|
||||||
|
<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 font-mono"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Save Templates Button -->
|
||||||
|
<div class="flex justify-content-end pt-2">
|
||||||
|
<Button
|
||||||
|
label="ذخیره قالبهای پیامک"
|
||||||
|
icon="pi pi-save"
|
||||||
|
class="font-bold"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveSmsTemplates"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Tab 1: Email Templates Placeholder -->
|
||||||
|
<TabPanel value="1">
|
||||||
|
<div class="p-5 text-center flex flex-column align-items-center justify-content-center gap-3">
|
||||||
|
<div class="w-4rem h-4rem border-round-2xl flex align-items-center justify-content-center bg-blue-50 text-blue-500">
|
||||||
|
<i class="pi pi-envelope text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-color m-0">قالبهای اعلان ایمیل</h3>
|
||||||
|
<p class="text-sm text-muted m-0 max-w-28rem line-height-3">
|
||||||
|
امکان ویرایش و سفارشیسازی قالبهای HTML ایمیل برای رویدادهای مختلف سیستم در بهروزرسانیهای آینده فعال خواهد شد.
|
||||||
|
</p>
|
||||||
|
<Tag value="بهزودی" severity="info" class="text-xs font-semibold px-3 py-1" />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Tab 2: Bale Bot Templates Placeholder -->
|
||||||
|
<TabPanel value="2">
|
||||||
|
<div class="p-5 text-center flex flex-column align-items-center justify-content-center gap-3">
|
||||||
|
<div class="w-4rem h-4rem border-round-2xl flex align-items-center justify-content-center bg-green-50 text-green-600">
|
||||||
|
<i class="pi pi-comments text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-color m-0">قالبهای بازوی بله</h3>
|
||||||
|
<p class="text-sm text-muted m-0 max-w-28rem line-height-3">
|
||||||
|
امکان تنظیم و شخصیسازی پیامها و کلیدهای شیشهای بازوی پیامرسان بله در بهروزرسانیهای آینده فعال خواهد شد.
|
||||||
|
</p>
|
||||||
|
<Tag value="بهزودی" severity="success" class="text-xs font-semibold px-3 py-1" />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
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 Button from 'primevue/button';
|
||||||
|
import InputSwitch from 'primevue/toggleswitch';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import { settingsApi } from '@/api/settingsApi';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const smsTemplates = ref([]);
|
||||||
|
const templateForm = ref({});
|
||||||
|
|
||||||
|
const TEMPLATE_SLOT_DEFS = {
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
certificateIssued: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'CERTIFICATETITLE' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
|
{ value: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
sessionCancelled: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
|
||||||
|
{ value: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
transactionRecorded: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'transactionCode', label: 'کد تراکنش', defaultName: 'TRANSACTIONCODE' },
|
||||||
|
{ value: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
|
||||||
|
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
|
||||||
|
{ value: 'receiptNumber', label: 'شماره رسید', defaultName: 'RECEIPTNUMBER' },
|
||||||
|
{ 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' }
|
||||||
|
],
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
classRegistered: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASS' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'CLASSDAYS' },
|
||||||
|
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'CLASSTIME' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
|
||||||
|
],
|
||||||
|
classPlanProfessor: [
|
||||||
|
{ value: 'professorName', label: 'نام استاد', defaultName: 'professorName' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||||||
|
{ value: 'classTimes', label: 'ساعت برگزاری', defaultName: 'classTimes' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classEndDate', label: 'تاریخ پایان', defaultName: 'classEndDate' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
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' }
|
||||||
|
],
|
||||||
|
classReminder: [
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
|
||||||
|
{ 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' }
|
||||||
|
],
|
||||||
|
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' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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: selectedSlot.defaultName || ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVariable = (templateKey, index) => {
|
||||||
|
const form = templateForm.value[templateKey];
|
||||||
|
if (form && form.variables) {
|
||||||
|
form.variables.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || '',
|
||||||
|
category: template?.category || 'اطلاعرسانی',
|
||||||
|
text: template?.text || '',
|
||||||
|
variables: vars
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get();
|
||||||
|
const data = response?.data?.data || response?.data || response || {};
|
||||||
|
const templates = Array.isArray(data.smsTemplates) ? data.smsTemplates : [];
|
||||||
|
smsTemplates.value = templates;
|
||||||
|
|
||||||
|
const newForm = {};
|
||||||
|
templates.forEach((template) => {
|
||||||
|
newForm[template.key] = emptyFormEntry(template);
|
||||||
|
});
|
||||||
|
templateForm.value = newForm;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSmsTemplates = async () => {
|
||||||
|
isSaving.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(),
|
||||||
|
category: entry.category || 'اطلاعرسانی',
|
||||||
|
text: String(entry.text || '').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 });
|
||||||
|
const data = response?.data?.data || response?.data || response || {};
|
||||||
|
if (Array.isArray(data.smsTemplates)) {
|
||||||
|
smsTemplates.value = data.smsTemplates;
|
||||||
|
const newForm = {};
|
||||||
|
data.smsTemplates.forEach((template) => {
|
||||||
|
newForm[template.key] = emptyFormEntry(template);
|
||||||
|
});
|
||||||
|
templateForm.value = newForm;
|
||||||
|
}
|
||||||
|
showSuccess('قالبهای پیامک با موفقیت ذخیره شدند.');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTemplates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-templates-view {
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,16 +3,15 @@
|
|||||||
<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="کانالهای اطلاعرسانی، تنظیمات اعلان، راهاندازی و وارد کردن دادهها"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
<TabList :scrollable="true">
|
<TabList :scrollable="true">
|
||||||
<Tab value="0">کانالها</Tab>
|
<Tab value="0">کانالها</Tab>
|
||||||
<Tab value="1">اطلاعرسانی</Tab>
|
<Tab value="1">اطلاعرسانی</Tab>
|
||||||
<Tab value="2">قالبهای پیامک</Tab>
|
<Tab value="2">راهاندازی</Tab>
|
||||||
<Tab value="3">راهاندازی</Tab>
|
<Tab value="3">وارد کردن داده</Tab>
|
||||||
<Tab value="4">وارد کردن داده</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
@@ -161,181 +160,6 @@
|
|||||||
|
|
||||||
<TabPanel value="2">
|
<TabPanel value="2">
|
||||||
<div class="p-4 sm:p-5">
|
<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="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">
|
||||||
<i class="pi pi-database text-xl"></i>
|
<i class="pi pi-database text-xl"></i>
|
||||||
@@ -376,7 +200,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value="4">
|
<TabPanel value="3">
|
||||||
<div class="p-4 sm:p-5">
|
<div class="p-4 sm:p-5">
|
||||||
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user