Files
CRM_Montzar/apps/web/src/views/MessageAutomationsView.vue
T

646 lines
16 KiB
Vue
Raw Normal View History

2026-09-13 19:11:47 +03:30
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
2026-09-13 21:22:41 +03:30
import MessageIntervalPicker from '@/components/MessageIntervalPicker.vue';
import { toFaDigits } from '@/lib/jalali';
2026-09-13 19:11:47 +03:30
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationChannel,
MessageAutomationDelayUnit,
MessageAutomationRuleDto,
MessageAutomationTarget,
MessageAutomationTrigger,
UpdateMessageAutomationRuleRequest,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_CHANNEL_LABELS,
MESSAGE_AUTOMATION_TARGET_LABELS,
MESSAGE_AUTOMATION_TRIGGER_LABELS,
MESSAGE_AUTOMATION_TRIGGERS,
MESSAGE_AUTOMATION_UNIT_LABELS,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<MessageAutomationRuleDto[]>([]);
const editingId = ref<string | null>(null);
const name = ref('');
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
2026-09-13 21:22:41 +03:30
const startDelayValue = ref(0);
const startDelayUnit = ref<MessageAutomationDelayUnit>('minutes');
2026-09-13 19:11:47 +03:30
const repeatCount = ref(1);
2026-09-13 21:22:41 +03:30
const intervalValue = ref(2);
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
2026-09-13 19:11:47 +03:30
const notifyTarget = ref<MessageAutomationTarget>('person');
const channel = ref<MessageAutomationChannel>('sms');
const title = ref('');
const body = ref('');
const personNote = computed(
() =>
notifyTarget.value !== 'staff' &&
'ناتیف داخل برنامه برای کارکنان است؛ برای مشتری پیامک ارسال می‌شود.',
);
function resetForm() {
editingId.value = null;
name.value = '';
trigger.value = 'invoice_issued';
2026-09-13 21:22:41 +03:30
startDelayValue.value = 0;
startDelayUnit.value = 'minutes';
2026-09-13 19:11:47 +03:30
repeatCount.value = 1;
2026-09-13 21:22:41 +03:30
intervalValue.value = 2;
intervalUnit.value = 'days';
2026-09-13 19:11:47 +03:30
notifyTarget.value = 'person';
channel.value = 'sms';
title.value = '';
body.value = '';
ok.value = '';
}
function startEdit(row: MessageAutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
trigger.value = row.trigger;
2026-09-13 21:22:41 +03:30
startDelayValue.value = row.startDelayValue;
startDelayUnit.value = row.startDelayUnit;
2026-09-13 19:11:47 +03:30
repeatCount.value = row.repeatCount;
2026-09-13 21:22:41 +03:30
intervalValue.value = row.intervalValue;
intervalUnit.value = row.intervalUnit;
2026-09-13 19:11:47 +03:30
notifyTarget.value = row.notifyTarget;
channel.value = row.channel;
title.value = row.title;
body.value = row.body;
ok.value = '';
}
function scheduleLabel(row: MessageAutomationRuleDto) {
const start =
row.startDelayValue === 0
? 'همان لحظه'
2026-09-13 21:22:41 +03:30
: `${toFaDigits(row.startDelayValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
2026-09-13 19:11:47 +03:30
if (row.repeatCount <= 1) return start;
2026-09-13 21:22:41 +03:30
return `${start} · ${toFaDigits(row.repeatCount)} بار، هر ${toFaDigits(row.intervalValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
2026-09-13 19:11:47 +03:30
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<MessageAutomationRuleDto[]>('/message-automations');
items.value = data;
} catch (e) {
error.value = axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری قوانین پیام ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateMessageAutomationRuleRequest {
return {
name: name.value.trim(),
2026-09-13 20:11:48 +03:30
isActive: true,
2026-09-13 19:11:47 +03:30
trigger: trigger.value,
2026-09-13 21:22:41 +03:30
startDelayValue: startDelayValue.value,
startDelayUnit: startDelayUnit.value,
2026-09-13 19:11:47 +03:30
repeatCount: repeatCount.value,
2026-09-13 21:22:41 +03:30
intervalValue: Math.max(1, intervalValue.value || 1),
intervalUnit: intervalUnit.value,
2026-09-13 19:11:47 +03:30
notifyTarget: notifyTarget.value,
channel: channel.value,
title: title.value.trim(),
body: body.value.trim(),
};
}
async function save() {
if (!name.value.trim() || !title.value.trim() || !body.value.trim()) {
error.value = 'عنوان قانون، عنوان پیام و متن را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
2026-09-13 20:11:48 +03:30
const { isActive: _ignore, ...rest } = payload();
const bodyDto: UpdateMessageAutomationRuleRequest = rest;
2026-09-13 19:11:47 +03:30
await api.patch(`/message-automations/${editingId.value}`, bodyDto);
ok.value = 'قانون به‌روز شد.';
} else {
await api.post('/message-automations', payload());
ok.value = 'قانون ساخته شد.';
}
resetForm();
await load();
} catch {
error.value = 'ذخیره قانون ناموفق بود.';
} finally {
saving.value = false;
}
}
async function toggle(row: MessageAutomationRuleDto) {
try {
await api.patch(`/message-automations/${row.id}`, { isActive: !row.isActive });
await load();
} catch {
error.value = 'تغییر وضعیت ناموفق بود.';
}
}
async function remove(row: MessageAutomationRuleDto) {
if (!confirm(`قانون «${row.name}» حذف شود؟`)) return;
try {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
await load();
} catch {
error.value = 'حذف قانون ناموفق بود.';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>پیام خودکار</h1>
<p class="lead">
جدا از اتوماسیون گردش کار. فقط برای پیامک و ناتیف: چه اتفاقی بیفتد، چند بار و با چه فاصله‌ای.
</p>
</div>
2026-09-13 20:11:48 +03:30
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</div>
2026-09-13 19:11:47 +03:30
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<form class="panel" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h2>
2026-09-13 20:11:48 +03:30
<div class="form-grid">
<label class="field span-6">
عنوان قانون
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
2026-09-13 19:11:47 +03:30
</label>
2026-09-13 20:11:48 +03:30
<label class="field span-6">
چه زمانی شروع شود
<select v-model="trigger">
<option
v-for="item in MESSAGE_AUTOMATION_TRIGGERS"
:key="item"
:value="item"
>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[item] }}
</option>
2026-09-13 19:11:47 +03:30
</select>
</label>
2026-09-13 20:11:48 +03:30
<div class="field span-5">
<span>شروع ارسال</span>
2026-09-13 21:22:41 +03:30
<MessageIntervalPicker
v-model:value="startDelayValue"
v-model:unit="startDelayUnit"
allow-immediate
2026-09-13 20:11:48 +03:30
/>
</div>
<label class="field span-2">
تعداد
2026-09-13 19:11:47 +03:30
<input v-model.number="repeatCount" type="number" min="1" max="10" />
</label>
2026-09-13 20:11:48 +03:30
<div v-if="repeatCount > 1" class="field span-5">
<span>فاصله بین پیام‌ها</span>
2026-09-13 21:22:41 +03:30
<MessageIntervalPicker
v-model:value="intervalValue"
v-model:unit="intervalUnit"
interval
2026-09-13 20:11:48 +03:30
/>
</div>
<label class="field span-4">
2026-09-13 19:11:47 +03:30
برای چه کسی
<select v-model="notifyTarget">
<option value="person">مشتری</option>
<option value="staff">مسئول پرونده</option>
<option value="both">مشتری و مسئول</option>
</select>
</label>
2026-09-13 20:11:48 +03:30
<label class="field span-4">
2026-09-13 19:11:47 +03:30
از چه راهی
<select v-model="channel">
<option value="sms">پیامک</option>
<option value="app">ناتیف داخل برنامه</option>
<option value="both">پیامک و ناتیف</option>
</select>
</label>
2026-09-13 20:11:48 +03:30
<label class="field span-4">
عنوان پیام
<input v-model="title" maxlength="120" placeholder="عنوان ناتیف یا ابتدای پیامک" />
</label>
<label class="field span-12">
متن پیام
<textarea
v-model="body"
maxlength="500"
placeholder="می‌توانید {title} را برای نام همان رکورد بگذارید"
/>
</label>
2026-09-13 19:11:47 +03:30
</div>
<p v-if="personNote" class="hint">{{ personNote }}</p>
<div class="form-actions">
<button class="btn primary" type="submit" :disabled="saving">
2026-09-13 20:11:48 +03:30
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ساخت' }}
2026-09-13 19:11:47 +03:30
</button>
<button v-if="editingId" class="btn ghost" type="button" @click="resetForm">
انصراف
</button>
</div>
</form>
2026-09-13 20:11:48 +03:30
<div class="list-wrap">
<table v-if="items.length" class="desk-table">
<thead>
<tr>
<th>عنوان</th>
<th>رویداد</th>
<th>زمان‌بندی</th>
<th>مخاطب</th>
<th>کانال</th>
<th>فعال</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id" :class="{ off: !row.isActive }">
<td>
<strong>{{ row.name }}</strong>
<span class="preview">{{ row.body }}</span>
</td>
<td>{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}</td>
<td>{{ scheduleLabel(row) }}</td>
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
<td>
<button
class="toggle"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
/>
</td>
<td class="ops">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
<button class="btn danger" type="button" @click="remove(row)">حذف</button>
</td>
</tr>
</tbody>
</table>
<div class="mobile-cards">
<article
v-for="row in items"
:key="row.id"
class="card"
:class="{ off: !row.isActive }"
>
<header>
<div>
<strong>{{ row.name }}</strong>
<p class="muted">{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}</p>
</div>
<button
class="toggle"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
/>
</header>
<p>{{ scheduleLabel(row) }}</p>
<p class="muted">
{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}
·
{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}
</p>
<p class="preview">{{ row.body }}</p>
<div class="card-actions">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
<button class="btn danger" type="button" @click="remove(row)">حذف</button>
</div>
</article>
</div>
2026-09-13 19:11:47 +03:30
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
2026-09-13 20:11:48 +03:30
width: 100%;
2026-09-13 19:11:47 +03:30
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
2026-09-13 20:11:48 +03:30
flex-wrap: wrap;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
2026-09-13 19:11:47 +03:30
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
h2 {
2026-09-13 20:11:48 +03:30
margin: 0 0 0.15rem;
2026-09-13 19:11:47 +03:30
font-size: 1.05rem;
font-weight: 700;
}
.lead,
.muted,
2026-09-13 20:11:48 +03:30
.hint {
2026-09-13 19:11:47 +03:30
margin: 0;
color: #64748b;
}
.hint {
font-size: 0.82rem;
}
2026-09-13 20:11:48 +03:30
.empty {
margin: 0;
padding: 1.4rem 0.2rem;
text-align: center;
color: #64748b;
2026-09-13 19:11:47 +03:30
}
2026-09-13 20:11:48 +03:30
.panel,
.list-wrap {
2026-09-13 19:11:47 +03:30
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
2026-09-13 20:11:48 +03:30
min-width: 0;
}
.list-wrap {
padding: 0.85rem 1.1rem;
}
.panel {
display: grid;
gap: 0.75rem;
padding: 1rem 1.1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.7rem 0.85rem;
2026-09-13 19:11:47 +03:30
}
.field {
display: grid;
2026-09-13 20:11:48 +03:30
gap: 0.3rem;
2026-09-13 19:11:47 +03:30
color: #64748b;
2026-09-13 20:11:48 +03:30
font-size: 0.86rem;
min-width: 0;
2026-09-13 19:11:47 +03:30
}
2026-09-13 20:11:48 +03:30
.field :deep(.wrap),
.field :deep(.trigger) {
width: 100%;
2026-09-13 19:11:47 +03:30
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
width: 100%;
box-sizing: border-box;
color: #0f172a;
}
textarea {
2026-09-13 20:11:48 +03:30
min-height: 4.2rem;
padding: 0.55rem 0.85rem;
2026-09-13 19:11:47 +03:30
}
.form-actions,
2026-09-13 20:11:48 +03:30
.card-actions,
.ops {
2026-09-13 19:11:47 +03:30
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
2026-09-13 20:11:48 +03:30
gap: 0.35rem;
2026-09-13 19:11:47 +03:30
min-height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: #14b8a6;
color: #fff;
2026-09-13 20:11:48 +03:30
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
2026-09-13 19:11:47 +03:30
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.danger {
background: #fff;
color: #b91c1c;
border-color: #fecaca;
}
2026-09-13 20:11:48 +03:30
.desk-table {
display: none;
width: 100%;
border-collapse: collapse;
}
.desk-table th,
.desk-table td {
padding: 0.85rem 0.9rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.desk-table th {
color: #94a3b8;
font-size: 0.75rem;
font-weight: 700;
}
.desk-table td {
font-size: 0.88rem;
color: #334155;
}
.desk-table td strong {
display: block;
color: #0f172a;
}
.desk-table tr.off {
opacity: 0.62;
}
.desk-table .preview {
display: block;
margin-top: 0.2rem;
color: #94a3b8;
font-size: 0.75rem;
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-cards {
2026-09-13 19:11:47 +03:30
display: grid;
2026-09-13 20:11:48 +03:30
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.4rem;
padding: 0.95rem 1rem;
border: 1px solid #eef2f7;
border-radius: 16px;
background: #f8fafc;
2026-09-13 19:11:47 +03:30
}
.card.off {
opacity: 0.7;
}
.card header {
display: flex;
2026-09-13 20:11:48 +03:30
align-items: flex-start;
2026-09-13 19:11:47 +03:30
justify-content: space-between;
gap: 0.75rem;
}
.card p {
margin: 0;
}
2026-09-13 20:11:48 +03:30
.card .preview {
2026-09-13 19:11:47 +03:30
color: #334155;
font-size: 0.88rem;
}
2026-09-13 20:11:48 +03:30
.toggle {
position: relative;
width: 42px;
height: 24px;
2026-09-13 19:11:47 +03:30
border: 0;
border-radius: 999px;
2026-09-13 20:11:48 +03:30
background: #cbd5e1;
2026-09-13 19:11:47 +03:30
cursor: pointer;
2026-09-13 20:11:48 +03:30
flex-shrink: 0;
padding: 0;
2026-09-13 19:11:47 +03:30
}
2026-09-13 20:11:48 +03:30
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.16);
transition: left 0.16s ease;
}
.toggle.on {
background: #14b8a6;
}
.toggle.on::after {
left: 20px;
2026-09-13 19:11:47 +03:30
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
2026-09-13 20:11:48 +03:30
@media (min-width: 900px) {
.form-grid {
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.span-2 { grid-column: span 2; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-6 { grid-column: span 6; }
.span-12 { grid-column: span 12; }
.desk-table {
display: table;
}
.mobile-cards {
display: none;
}
}
2026-09-13 19:11:47 +03:30
</style>