Edit SMS automation
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "MessageAutomationDelayUnit" ADD VALUE IF NOT EXISTS 'weeks';
|
||||
ALTER TYPE "MessageAutomationDelayUnit" ADD VALUE IF NOT EXISTS 'months';
|
||||
@@ -1104,6 +1104,8 @@ enum MessageAutomationDelayUnit {
|
||||
minutes
|
||||
hours
|
||||
days
|
||||
weeks
|
||||
months
|
||||
}
|
||||
|
||||
enum MessageAutomationChannel {
|
||||
|
||||
@@ -14,6 +14,7 @@ function testDelay() {
|
||||
assert(delayMs(0, 'minutes') === 0, 'zero delay');
|
||||
assert(delayMs(2, 'hours') === 2 * 3_600_000, '2 hours');
|
||||
assert(delayMs(2, 'days') === 2 * 86_400_000, '2 days');
|
||||
assert(delayMs(1, 'weeks') === 7 * 86_400_000, '1 week');
|
||||
}
|
||||
|
||||
function testSendAts() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type MessageAutomationTrigger,
|
||||
} from '@mento/shared';
|
||||
|
||||
const UNITS = ['minutes', 'hours', 'days'] as const;
|
||||
const UNITS = ['minutes', 'hours', 'days', 'weeks', 'months'] as const;
|
||||
const CHANNELS = ['app', 'sms', 'both'] as const;
|
||||
const TARGETS = ['staff', 'person', 'both'] as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MessageDelayUnit = 'minutes' | 'hours' | 'days';
|
||||
export type MessageDelayUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
|
||||
|
||||
export const MESSAGE_AUTOMATION_TRIGGERS = [
|
||||
'person_created',
|
||||
@@ -22,7 +22,23 @@ export function delayMs(value: number, unit: MessageDelayUnit): number {
|
||||
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;
|
||||
if (unit === 'days') return n * 86_400_000;
|
||||
if (unit === 'weeks') return n * 7 * 86_400_000;
|
||||
return n * 30 * 86_400_000;
|
||||
}
|
||||
|
||||
export function addDelay(
|
||||
from: Date,
|
||||
value: number,
|
||||
unit: MessageDelayUnit,
|
||||
): Date {
|
||||
const n = Math.max(0, Math.trunc(Number(value) || 0));
|
||||
if (unit === 'months') {
|
||||
const next = new Date(from.getTime());
|
||||
next.setMonth(next.getMonth() + n);
|
||||
return next;
|
||||
}
|
||||
return new Date(from.getTime() + delayMs(n, unit));
|
||||
}
|
||||
|
||||
export function buildSendAts(
|
||||
@@ -37,9 +53,14 @@ export function buildSendAts(
|
||||
MAX_MESSAGE_REPEAT,
|
||||
Math.max(1, Math.trunc(Number(repeatCount) || 1)),
|
||||
);
|
||||
const first = new Date(now.getTime() + delayMs(startValue, startUnit));
|
||||
const gap = delayMs(Math.max(1, intervalValue), intervalUnit);
|
||||
return Array.from({ length: count }, (_, i) => new Date(first.getTime() + i * gap));
|
||||
const first = addDelay(now, startValue, startUnit);
|
||||
const times = [first];
|
||||
let cursor = first;
|
||||
for (let i = 1; i < count; i += 1) {
|
||||
cursor = addDelay(cursor, Math.max(1, intervalValue), intervalUnit);
|
||||
times.push(cursor);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
export function applyMessageTemplate(text: string, title: string): string {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import type { MessageAutomationDelayUnit } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const value = defineModel<number>('value', { default: 0 });
|
||||
const unit = defineModel<MessageAutomationDelayUnit>('unit', { default: 'days' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
allowImmediate?: boolean;
|
||||
interval?: boolean;
|
||||
}>(),
|
||||
{
|
||||
allowImmediate: false,
|
||||
interval: false,
|
||||
},
|
||||
);
|
||||
|
||||
const GROUPS: {
|
||||
unit: MessageAutomationDelayUnit;
|
||||
title: string;
|
||||
hintStart: string;
|
||||
hintInterval: string;
|
||||
max: number;
|
||||
dense?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
unit: 'hours',
|
||||
title: 'ساعت',
|
||||
hintStart: 'از ۱ تا ۲۳ ساعت بعد',
|
||||
hintInterval: 'هر ۱ تا ۲۳ ساعت یکبار',
|
||||
max: 23,
|
||||
dense: true,
|
||||
},
|
||||
{
|
||||
unit: 'days',
|
||||
title: 'روز',
|
||||
hintStart: 'از ۱ تا ۶ روز بعد',
|
||||
hintInterval: 'هر ۱ تا ۶ روز یکبار',
|
||||
max: 6,
|
||||
},
|
||||
{
|
||||
unit: 'weeks',
|
||||
title: 'هفته',
|
||||
hintStart: 'از ۱ تا ۳ هفته بعد',
|
||||
hintInterval: 'هر ۱ تا ۳ هفته یکبار',
|
||||
max: 3,
|
||||
},
|
||||
{
|
||||
unit: 'months',
|
||||
title: 'ماه',
|
||||
hintStart: 'از ۱ تا ۱۱ ماه بعد',
|
||||
hintInterval: 'هر ۱ تا ۱۱ ماه یکبار',
|
||||
max: 11,
|
||||
dense: true,
|
||||
},
|
||||
];
|
||||
|
||||
const UNIT_WORD: Record<MessageAutomationDelayUnit, string> = {
|
||||
minutes: 'دقیقه',
|
||||
hours: 'ساعت',
|
||||
days: 'روز',
|
||||
weeks: 'هفته',
|
||||
months: 'ماه',
|
||||
};
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const display = computed(() => {
|
||||
if (props.allowImmediate && (!value.value || value.value <= 0)) return 'همان لحظه';
|
||||
const n = Math.max(1, value.value || 1);
|
||||
const word = UNIT_WORD[unit.value] ?? 'روز';
|
||||
return props.interval
|
||||
? `هر ${toFaDigits(n)} ${word} یکبار`
|
||||
: `${toFaDigits(n)} ${word} بعد`;
|
||||
});
|
||||
|
||||
const isSelected = (itemUnit: MessageAutomationDelayUnit, n: number) =>
|
||||
unit.value === itemUnit && Number(value.value) === n;
|
||||
|
||||
function pick(itemUnit: MessageAutomationDelayUnit, n: number) {
|
||||
value.value = n;
|
||||
unit.value = itemUnit;
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function pickImmediate() {
|
||||
value.value = 0;
|
||||
unit.value = 'minutes';
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') open.value = false;
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : '';
|
||||
});
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey));
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<button class="trigger" type="button" :aria-expanded="open" @click="open = true">
|
||||
<span>{{ display }}</span>
|
||||
<UiIcon name="clock" :size="18" />
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="layer">
|
||||
<button class="dim" type="button" aria-label="بستن" @click="open = false" />
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-label="انتخاب فاصله زمانی">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{{ interval ? 'فاصله بین پیامها' : 'شروع ارسال' }}</h3>
|
||||
<p>
|
||||
{{
|
||||
interval
|
||||
? 'پیام بعدی بعد از چند وقت برود؟'
|
||||
: 'اولین پیام کی بعد از رویداد برود؟'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button class="x" type="button" aria-label="بستن" @click="open = false">
|
||||
<UiIcon name="x" :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<button
|
||||
v-if="allowImmediate"
|
||||
class="now"
|
||||
type="button"
|
||||
:class="{ on: !value }"
|
||||
@click="pickImmediate"
|
||||
>
|
||||
<span>همان لحظه</span>
|
||||
<small>بلافاصله بعد از رویداد</small>
|
||||
</button>
|
||||
|
||||
<div v-for="group in GROUPS" :key="group.unit" class="group">
|
||||
<div class="group-head">
|
||||
<strong>{{ group.title }}</strong>
|
||||
<span>{{ interval ? group.hintInterval : group.hintStart }}</span>
|
||||
</div>
|
||||
<div class="chips" :class="{ dense: group.dense }">
|
||||
<button
|
||||
v-for="n in group.max"
|
||||
:key="`${group.unit}-${n}`"
|
||||
class="chip"
|
||||
type="button"
|
||||
:class="{ on: isSelected(group.unit, n) }"
|
||||
:aria-label="`${toFaDigits(n)} ${group.title}`"
|
||||
@click="pick(group.unit, n)"
|
||||
>
|
||||
{{ toFaDigits(n) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap,
|
||||
.trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: end center;
|
||||
}
|
||||
|
||||
.dim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(36rem, 100%);
|
||||
max-height: min(88dvh, 46rem);
|
||||
overflow: auto;
|
||||
padding: 1.15rem 1.2rem calc(1.2rem + env(safe-area-inset-bottom));
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 -16px 40px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.95rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.22rem 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.x {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.now {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
width: 100%;
|
||||
min-height: 3.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.now small {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.group-head strong {
|
||||
font-size: 0.9rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.group-head span {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(3.1rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chips.dense {
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.55rem, 1fr));
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-height: 2.55rem;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.now.on,
|
||||
.chip.on {
|
||||
background: #ecfdf8;
|
||||
border-color: #99f6e4;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.now.on small {
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.layer {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
border-radius: 24px;
|
||||
max-height: min(80vh, 44rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +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 MessageIntervalPicker from '@/components/MessageIntervalPicker.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type {
|
||||
CreateMessageAutomationRuleRequest,
|
||||
MessageAutomationChannel,
|
||||
@@ -30,9 +30,11 @@ const editingId = ref<string | null>(null);
|
||||
|
||||
const name = ref('');
|
||||
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
|
||||
const startAt = ref('');
|
||||
const startDelayValue = ref(0);
|
||||
const startDelayUnit = ref<MessageAutomationDelayUnit>('minutes');
|
||||
const repeatCount = ref(1);
|
||||
const intervalAt = ref('');
|
||||
const intervalValue = ref(2);
|
||||
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
|
||||
const notifyTarget = ref<MessageAutomationTarget>('person');
|
||||
const channel = ref<MessageAutomationChannel>('sms');
|
||||
const title = ref('');
|
||||
@@ -44,38 +46,15 @@ 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 = '';
|
||||
trigger.value = 'invoice_issued';
|
||||
startAt.value = '';
|
||||
startDelayValue.value = 0;
|
||||
startDelayUnit.value = 'minutes';
|
||||
repeatCount.value = 1;
|
||||
intervalAt.value = delayToIso(2, 'days');
|
||||
intervalValue.value = 2;
|
||||
intervalUnit.value = 'days';
|
||||
notifyTarget.value = 'person';
|
||||
channel.value = 'sms';
|
||||
title.value = '';
|
||||
@@ -87,9 +66,11 @@ function startEdit(row: MessageAutomationRuleDto) {
|
||||
editingId.value = row.id;
|
||||
name.value = row.name;
|
||||
trigger.value = row.trigger;
|
||||
startAt.value = delayToIso(row.startDelayValue, row.startDelayUnit);
|
||||
startDelayValue.value = row.startDelayValue;
|
||||
startDelayUnit.value = row.startDelayUnit;
|
||||
repeatCount.value = row.repeatCount;
|
||||
intervalAt.value = delayToIso(row.intervalValue, row.intervalUnit);
|
||||
intervalValue.value = row.intervalValue;
|
||||
intervalUnit.value = row.intervalUnit;
|
||||
notifyTarget.value = row.notifyTarget;
|
||||
channel.value = row.channel;
|
||||
title.value = row.title;
|
||||
@@ -101,9 +82,9 @@ function scheduleLabel(row: MessageAutomationRuleDto) {
|
||||
const start =
|
||||
row.startDelayValue === 0
|
||||
? 'همان لحظه'
|
||||
: `${row.startDelayValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
|
||||
: `${toFaDigits(row.startDelayValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
|
||||
if (row.repeatCount <= 1) return start;
|
||||
return `${start} · ${row.repeatCount} بار، هر ${row.intervalValue} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
|
||||
return `${start} · ${toFaDigits(row.repeatCount)} بار، هر ${toFaDigits(row.intervalValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -122,17 +103,15 @@ async function load() {
|
||||
}
|
||||
|
||||
function payload(): CreateMessageAutomationRuleRequest {
|
||||
const start = isoToDelay(startAt.value);
|
||||
const interval = isoToDelay(intervalAt.value);
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
isActive: true,
|
||||
trigger: trigger.value,
|
||||
startDelayValue: start.value,
|
||||
startDelayUnit: start.unit,
|
||||
startDelayValue: startDelayValue.value,
|
||||
startDelayUnit: startDelayUnit.value,
|
||||
repeatCount: repeatCount.value,
|
||||
intervalValue: Math.max(1, interval.value || 1),
|
||||
intervalUnit: interval.value ? interval.unit : 'days',
|
||||
intervalValue: Math.max(1, intervalValue.value || 1),
|
||||
intervalUnit: intervalUnit.value,
|
||||
notifyTarget: notifyTarget.value,
|
||||
channel: channel.value,
|
||||
title: title.value.trim(),
|
||||
@@ -230,10 +209,10 @@ onMounted(load);
|
||||
</label>
|
||||
<div class="field span-5">
|
||||
<span>شروع ارسال</span>
|
||||
<JalaliDateInput
|
||||
v-model="startAt"
|
||||
mode="datetime"
|
||||
placeholder="همان لحظه"
|
||||
<MessageIntervalPicker
|
||||
v-model:value="startDelayValue"
|
||||
v-model:unit="startDelayUnit"
|
||||
allow-immediate
|
||||
/>
|
||||
</div>
|
||||
<label class="field span-2">
|
||||
@@ -242,10 +221,10 @@ onMounted(load);
|
||||
</label>
|
||||
<div v-if="repeatCount > 1" class="field span-5">
|
||||
<span>فاصله بین پیامها</span>
|
||||
<JalaliDateInput
|
||||
v-model="intervalAt"
|
||||
mode="datetime"
|
||||
placeholder="انتخاب روز و ساعت"
|
||||
<MessageIntervalPicker
|
||||
v-model:value="intervalValue"
|
||||
v-model:unit="intervalUnit"
|
||||
interval
|
||||
/>
|
||||
</div>
|
||||
<label class="field span-4">
|
||||
|
||||
@@ -2261,7 +2261,12 @@ export type MessageAutomationTrigger =
|
||||
| "activity_created"
|
||||
| "activity_done";
|
||||
|
||||
export type MessageAutomationDelayUnit = "minutes" | "hours" | "days";
|
||||
export type MessageAutomationDelayUnit =
|
||||
| "minutes"
|
||||
| "hours"
|
||||
| "days"
|
||||
| "weeks"
|
||||
| "months";
|
||||
export type MessageAutomationChannel = "app" | "sms" | "both";
|
||||
export type MessageAutomationTarget = "staff" | "person" | "both";
|
||||
|
||||
@@ -2305,6 +2310,8 @@ export const MESSAGE_AUTOMATION_UNIT_LABELS: Record<
|
||||
minutes: "دقیقه",
|
||||
hours: "ساعت",
|
||||
days: "روز",
|
||||
weeks: "هفته",
|
||||
months: "ماه",
|
||||
};
|
||||
|
||||
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
|
||||
|
||||
Reference in New Issue
Block a user