Edit UI SMS automation

This commit is contained in:
2026-09-13 20:11:48 +03:30
parent 45b0d46cfe
commit cc23e453de
+324 -165
View File
@@ -2,6 +2,8 @@
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, parseLocalInput } from '@/lib/jalali';
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationChannel,
@@ -27,13 +29,10 @@ const items = ref<MessageAutomationRuleDto[]>([]);
const editingId = ref<string | null>(null);
const name = ref('');
const isActive = ref(true);
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
const startDelayValue = ref(0);
const startDelayUnit = ref<MessageAutomationDelayUnit>('minutes');
const startAt = ref('');
const repeatCount = ref(1);
const intervalValue = ref(2);
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
const intervalAt = ref('');
const notifyTarget = ref<MessageAutomationTarget>('person');
const channel = ref<MessageAutomationChannel>('sms');
const title = ref('');
@@ -45,16 +44,38 @@ const personNote = computed(
'ناتیف داخل برنامه برای کارکنان است؛ برای مشتری پیامک ارسال می‌شود.',
);
function delayMs(value: number, unit: MessageAutomationDelayUnit) {
const n = Math.max(0, Math.trunc(Number(value) || 0));
if (unit === 'minutes') return n * 60_000;
if (unit === 'hours') return n * 3_600_000;
return n * 86_400_000;
}
function delayToIso(value: number, unit: MessageAutomationDelayUnit) {
if (!value) return '';
return formatLocalDateTimeInput(new Date(Date.now() + delayMs(value, unit)));
}
function isoToDelay(iso: string): {
value: number;
unit: MessageAutomationDelayUnit;
} {
const date = parseLocalInput(iso);
if (!date) return { value: 0, unit: 'minutes' };
const mins = Math.max(0, Math.round((date.getTime() - Date.now()) / 60_000));
if (mins === 0) return { value: 0, unit: 'minutes' };
if (mins % (24 * 60) === 0) return { value: mins / (24 * 60), unit: 'days' };
if (mins % 60 === 0) return { value: mins / 60, unit: 'hours' };
return { value: mins, unit: 'minutes' };
}
function resetForm() {
editingId.value = null;
name.value = '';
isActive.value = true;
trigger.value = 'invoice_issued';
startDelayValue.value = 0;
startDelayUnit.value = 'minutes';
startAt.value = '';
repeatCount.value = 1;
intervalValue.value = 2;
intervalUnit.value = 'days';
intervalAt.value = delayToIso(2, 'days');
notifyTarget.value = 'person';
channel.value = 'sms';
title.value = '';
@@ -65,13 +86,10 @@ function resetForm() {
function startEdit(row: MessageAutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
isActive.value = row.isActive;
trigger.value = row.trigger;
startDelayValue.value = row.startDelayValue;
startDelayUnit.value = row.startDelayUnit;
startAt.value = delayToIso(row.startDelayValue, row.startDelayUnit);
repeatCount.value = row.repeatCount;
intervalValue.value = row.intervalValue;
intervalUnit.value = row.intervalUnit;
intervalAt.value = delayToIso(row.intervalValue, row.intervalUnit);
notifyTarget.value = row.notifyTarget;
channel.value = row.channel;
title.value = row.title;
@@ -104,15 +122,17 @@ async function load() {
}
function payload(): CreateMessageAutomationRuleRequest {
const start = isoToDelay(startAt.value);
const interval = isoToDelay(intervalAt.value);
return {
name: name.value.trim(),
isActive: isActive.value,
isActive: true,
trigger: trigger.value,
startDelayValue: startDelayValue.value,
startDelayUnit: startDelayUnit.value,
startDelayValue: start.value,
startDelayUnit: start.unit,
repeatCount: repeatCount.value,
intervalValue: intervalValue.value,
intervalUnit: intervalUnit.value,
intervalValue: Math.max(1, interval.value || 1),
intervalUnit: interval.value ? interval.unit : 'days',
notifyTarget: notifyTarget.value,
channel: channel.value,
title: title.value.trim(),
@@ -130,7 +150,8 @@ async function save() {
ok.value = '';
try {
if (editingId.value) {
const bodyDto: UpdateMessageAutomationRuleRequest = payload();
const { isActive: _ignore, ...rest } = payload();
const bodyDto: UpdateMessageAutomationRuleRequest = rest;
await api.patch(`/message-automations/${editingId.value}`, bodyDto);
ok.value = 'قانون به‌روز شد.';
} else {
@@ -178,6 +199,11 @@ onMounted(load);
جدا از اتوماسیون گردش کار. فقط برای پیامک و ناتیف: چه اتفاقی بیفتد، چند بار و با چه فاصله‌ای.
</p>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
@@ -185,59 +211,44 @@ onMounted(load);
<form class="panel" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h2>
<label class="field">
عنوان قانون
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
</label>
<label class="field">
چه زمانی شروع شود
<select v-model="trigger">
<option
v-for="item in MESSAGE_AUTOMATION_TRIGGERS"
:key="item"
:value="item"
>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[item] }}
</option>
</select>
</label>
<div class="row-fields">
<label class="field">
شروع ارسال
<input v-model.number="startDelayValue" type="number" min="0" max="365" />
<div class="form-grid">
<label class="field span-6">
عنوان قانون
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
</label>
<label class="field">
واحد شروع
<select v-model="startDelayUnit">
<option value="minutes">دقیقه بعد</option>
<option value="hours">ساعت بعد</option>
<option value="days">روز بعد</option>
<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>
</select>
</label>
<label class="field">
تعداد ارسال
<div class="field span-5">
<span>شروع ارسال</span>
<JalaliDateInput
v-model="startAt"
mode="datetime"
placeholder="همان لحظه"
/>
</div>
<label class="field span-2">
تعداد
<input v-model.number="repeatCount" type="number" min="1" max="10" />
</label>
</div>
<div v-if="repeatCount > 1" class="row-fields">
<label class="field">
فاصله بین پیام‌ها
<input v-model.number="intervalValue" type="number" min="1" max="365" />
</label>
<label class="field">
واحد فاصله
<select v-model="intervalUnit">
<option value="minutes">دقیقه</option>
<option value="hours">ساعت</option>
<option value="days">روز</option>
</select>
</label>
</div>
<p class="hint">
صفر یعنی همان لحظه. برای چندباره: مثلاً ۳ بار، هر ۲ روز.
</p>
<div class="row-fields">
<label class="field">
<div v-if="repeatCount > 1" class="field span-5">
<span>فاصله بین پیام‌ها</span>
<JalaliDateInput
v-model="intervalAt"
mode="datetime"
placeholder="انتخاب روز و ساعت"
/>
</div>
<label class="field span-4">
برای چه کسی
<select v-model="notifyTarget">
<option value="person">مشتری</option>
@@ -245,7 +256,7 @@ onMounted(load);
<option value="both">مشتری و مسئول</option>
</select>
</label>
<label class="field">
<label class="field span-4">
از چه راهی
<select v-model="channel">
<option value="sms">پیامک</option>
@@ -253,27 +264,23 @@ onMounted(load);
<option value="both">پیامک و ناتیف</option>
</select>
</label>
<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>
</div>
<p v-if="personNote" class="hint">{{ personNote }}</p>
<label class="field">
عنوان پیام
<input v-model="title" maxlength="120" placeholder="عنوان ناتیف یا ابتدای پیامک" />
</label>
<label class="field">
متن پیام
<textarea
v-model="body"
maxlength="500"
placeholder="می‌توانید {title} را برای نام همان رکورد بگذارید"
/>
</label>
<label class="check">
<input v-model="isActive" type="checkbox" />
قانون فعال باشد
</label>
<div class="form-actions">
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? '…' : editingId ? 'ذخیره تغییرات' : 'ساخت قانون' }}
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ساخت' }}
</button>
<button v-if="editingId" class="btn ghost" type="button" @click="resetForm">
انصراف
@@ -281,35 +288,81 @@ onMounted(load);
</div>
</form>
<div class="list">
<article v-for="row in items" :key="row.id" class="card" :class="{ off: !row.isActive }">
<header>
<strong>{{ row.name }}</strong>
<button
class="switch"
type="button"
:aria-pressed="row.isActive"
@click="toggle(row)"
>
{{ row.isActive ? 'فعال' : 'خاموش' }}
</button>
</header>
<p>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}
·
{{ 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 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>
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
</section>
@@ -319,14 +372,26 @@ onMounted(load);
.page {
display: grid;
gap: 1rem;
max-width: 760px;
min-width: 0;
width: 100%;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
h1 {
@@ -336,15 +401,14 @@ h1 {
}
h2 {
margin: 0;
margin: 0 0 0.15rem;
font-size: 1.05rem;
font-weight: 700;
}
.lead,
.muted,
.hint,
.empty {
.hint {
margin: 0;
color: #64748b;
}
@@ -353,36 +417,49 @@ h2 {
font-size: 0.82rem;
}
.panel,
.list {
display: grid;
gap: 0.75rem;
.empty {
margin: 0;
padding: 1.4rem 0.2rem;
text-align: center;
color: #64748b;
}
.panel {
.panel,
.list-wrap {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
padding: 1.1rem;
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;
}
.field {
display: grid;
gap: 0.35rem;
gap: 0.3rem;
color: #64748b;
font-size: 0.9rem;
font-size: 0.86rem;
min-width: 0;
}
.row-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.row-fields .field {
flex: 1;
min-width: 8rem;
.field :deep(.wrap),
.field :deep(.trigger) {
width: 100%;
}
input,
@@ -400,19 +477,13 @@ textarea {
}
textarea {
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
color: #334155;
min-height: 4.2rem;
padding: 0.55rem 0.85rem;
}
.form-actions,
.card-actions {
.card-actions,
.ops {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
@@ -422,6 +493,7 @@ textarea {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
@@ -434,6 +506,7 @@ textarea {
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
@@ -448,13 +521,63 @@ textarea {
border-color: #fecaca;
}
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 1rem;
.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 {
display: grid;
gap: 0.35rem;
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.4rem;
padding: 0.95rem 1rem;
border: 1px solid #eef2f7;
border-radius: 16px;
background: #f8fafc;
}
.card.off {
@@ -463,7 +586,7 @@ textarea {
.card header {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
@@ -472,26 +595,42 @@ textarea {
margin: 0;
}
.preview {
.card .preview {
color: #334155;
font-size: 0.88rem;
}
.switch {
.toggle {
position: relative;
width: 42px;
height: 24px;
border: 0;
background: #ecfdf8;
color: #0f766e;
font: inherit;
font-weight: 800;
font-size: 0.78rem;
border-radius: 999px;
padding: 0.3rem 0.7rem;
background: #cbd5e1;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.card.off .switch {
background: #f1f5f9;
color: #64748b;
.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;
}
.error {
@@ -504,4 +643,24 @@ textarea {
color: #0f766e;
font-weight: 600;
}
@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;
}
}
</style>