Redesign of the SMS automation page

This commit is contained in:
2026-09-14 16:49:43 +03:30
parent 8925c77ae0
commit f176fde1eb
15 changed files with 2089 additions and 226 deletions
@@ -0,0 +1 @@
ALTER TABLE "MessageAutomationRule" ADD COLUMN IF NOT EXISTS "description" TEXT;
+1
View File
@@ -1142,6 +1142,7 @@ model MessageAutomationRule {
notifyTarget MessageAutomationTarget @default(person)
channel MessageAutomationChannel @default(sms)
title String
description String?
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -46,9 +46,16 @@ function testCancelMap() {
function testTemplate() {
assert(
applyMessageTemplate('سلام {title}', 'فاکتور ۱۲') === 'سلام فاکتور ۱۲',
applyMessageTemplate('سلام {title}', { title: 'فاکتور ۱۲' }) === 'سلام فاکتور ۱۲',
'title var',
);
assert(
applyMessageTemplate('سلام {نام} {شماره}', {
name: 'علی',
phone: '0912',
}) === 'سلام علی 0912',
'name phone vars',
);
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
}
+10
View File
@@ -72,6 +72,11 @@ export class CreateMessageAutomationRuleDto {
@IsIn(CHANNELS)
channel?: MessageAutomationChannel;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@IsString()
@MinLength(2)
@MaxLength(120)
@@ -135,6 +140,11 @@ export class UpdateMessageAutomationRuleDto {
@IsIn(CHANNELS)
channel?: MessageAutomationChannel;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@IsOptional()
@IsString()
@MinLength(2)
@@ -63,8 +63,32 @@ export function buildSendAts(
return times;
}
export function applyMessageTemplate(text: string, title: string): string {
return text.replace(/\{title\}/g, title).replace(/\{عنوان\}/g, title);
export function applyMessageTemplate(
text: string,
vars: {
title?: string;
name?: string;
phone?: string;
date?: string;
company?: string;
},
): string {
const map: Record<string, string> = {
title: vars.title ?? '',
عنوان: vars.title ?? '',
نام: vars.name ?? '',
name: vars.name ?? '',
شماره: vars.phone ?? '',
phone: vars.phone ?? '',
تاریخ: vars.date ?? '',
date: vars.date ?? '',
شرکت: vars.company ?? '',
company: vars.company ?? '',
};
return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()];
return next === undefined ? full : next;
});
}
export function clipSms(text: string): string {
@@ -14,6 +14,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsService } from '../common/sms.service';
import { dateToJalali, jalaliMonthName } from '../common/jalali';
import {
applyMessageTemplate,
buildSendAts,
@@ -97,6 +98,9 @@ export class MessageAutomationsService {
: {}),
...(dto.channel !== undefined ? { channel: dto.channel } : {}),
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.description !== undefined
? { description: dto.description?.trim() || null }
: {}),
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
},
});
@@ -328,11 +332,33 @@ export class MessageAutomationsService {
notifyTarget: string;
};
}) {
const title = applyMessageTemplate(job.rule.title, job.entityTitle).slice(
0,
200,
);
const body = applyMessageTemplate(job.rule.body, job.entityTitle);
const person = job.personId
? await this.prisma.person.findFirst({
where: {
id: job.personId,
organizationId: job.organizationId,
},
select: {
firstName: true,
lastName: true,
mobile: true,
phone: true,
company: { select: { name: true } },
},
})
: null;
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
const vars = {
title: job.entityTitle,
name: personName || job.entityTitle,
phone: person?.mobile || person?.phone || '',
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
};
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
const body = applyMessageTemplate(job.rule.body, vars);
const smsText = clipSms(body || title);
const wantApp = job.rule.channel === 'app' || job.rule.channel === 'both';
const wantSms = job.rule.channel === 'sms' || job.rule.channel === 'both';
@@ -366,15 +392,6 @@ export class MessageAutomationsService {
}
if (targetPerson && wantSms) {
const person = job.personId
? await this.prisma.person.findFirst({
where: {
id: job.personId,
organizationId: job.organizationId,
},
select: { mobile: true, phone: true },
})
: null;
await this.sendSms(person?.mobile || person?.phone, smsText);
}
}
@@ -413,6 +430,7 @@ export class MessageAutomationsService {
notifyTarget: dto.notifyTarget ?? 'person',
channel: dto.channel ?? 'sms',
title: dto.title.trim(),
description: dto.description?.trim() || null,
body: dto.body.trim(),
};
}
@@ -430,6 +448,7 @@ export class MessageAutomationsService {
notifyTarget: string;
channel: string;
title: string;
description?: string | null;
body: string;
createdAt: Date;
updatedAt: Date;
@@ -447,6 +466,7 @@ export class MessageAutomationsService {
notifyTarget: row.notifyTarget as MessageAutomationRuleDto['notifyTarget'],
channel: row.channel as MessageAutomationRuleDto['channel'],
title: row.title,
description: row.description ?? null,
body: row.body,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -454,6 +474,15 @@ export class MessageAutomationsService {
}
}
function toFaDigits(value: number | string) {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
function formatJalaliDayFa(d: Date) {
const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
}
function entityLink(entityType: string, entityId: string) {
if (entityType === 'deal') return `/deals/${entityId}`;
if (entityType === 'ticket') return `/tickets/${entityId}`;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

-14
View File
@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="mentoLogoBg" x1="8" y1="8" x2="56" y2="56">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="100%" stop-color="#14b8a6"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#mentoLogoBg)"/>
<rect x="14" y="16" width="8" height="32" rx="4" fill="#fff" opacity="0.95"/>
<path d="M17 17.5c1-1.6 3.5-1.8 4.8-.5L36 30l4.3-4c1.4-1.3 3.6-1.2 4.7.4L44 27.5 33 37.5c-1.1 1-2.8 1-3.9 0L16.5 24.5 17 17.5Z" fill="#134e4a"/>
<path d="M27.5 32 38 21.5c1.3-1.3 3.4-1.2 4.6.2L50 29.5l-2.6 2.6L40 25.5 31 34c-1 1-2.8 1-3.8-.1l-.7-.9Z" fill="#ecfdf5"/>
<rect x="42" y="16" width="8" height="32" rx="4" fill="#134e4a"/>
<path d="M41 17.5c1.3-1.4 3.5-1.3 4.7.2l1.6 1.8-7 6.6-2.1-2c-1.2-1.1-.9-3.2 2.8-6.6Z" fill="#ecfdf5"/>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

+14 -1
View File
@@ -32,6 +32,7 @@ defineProps<{
| 'chevron'
| 'star'
| 'filter'
| 'funnel'
| 'lightbulb'
| 'building'
| 'user'
@@ -58,7 +59,9 @@ defineProps<{
| 'crown'
| 'gift'
| 'medal'
| 'logout';
| 'logout'
| 'pulse'
| 'sliders';
size?: number;
}>();
</script>
@@ -221,6 +224,9 @@ defineProps<{
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'funnel'">
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
@@ -346,6 +352,13 @@ defineProps<{
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</template>
<template v-else-if="name === 'pulse'">
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
</template>
<template v-else-if="name === 'sliders'">
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</template>
</svg>
</template>
+20 -23
View File
@@ -48,9 +48,10 @@ const salesActive = computed(() =>
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(() => route.path.startsWith('/settings/automations'));
const messageAutomationsActive = computed(() =>
route.path.startsWith('/settings/message-automations'),
const automationsActive = computed(
() =>
route.path.startsWith('/settings/automations') ||
route.path.startsWith('/settings/message-automations'),
);
const toolsActive = computed(() =>
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
@@ -403,28 +404,24 @@ onUnmounted(() => {
<UiIcon name="chart" :size="collapsed ? 20 : 18" />
<span>گزارش‌ها</span>
</RouterLink>
<RouterLink
<div
v-if="canSettings"
to="/settings/automations"
class="nav-item"
title="اتوماسیون"
:class="{ 'router-link-active': automationsActive }"
@click="closeHub(); closeMenu()"
class="nav-fold"
:class="{ active: automationsActive || openHubKey === 'automations' }"
>
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
</RouterLink>
<RouterLink
v-if="canSettings"
to="/settings/message-automations"
class="nav-item"
title="پیام خودکار"
:class="{ 'router-link-active': messageAutomationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="mail" :size="collapsed ? 20 : 18" />
<span>پیام خودکار</span>
</RouterLink>
<button
class="nav-item nav-fold-btn"
type="button"
title="اتوماسیون"
:class="{ 'router-link-active': automationsActive || openHubKey === 'automations', 'hub-on': openHubKey === 'automations' }"
:aria-expanded="openHubKey === 'automations'"
@click="onHubClick('automations')"
>
<UiIcon name="layers" :size="collapsed ? 20 : 18" />
<span>اتوماسیون</span>
<i class="chev" aria-hidden="true" />
</button>
</div>
</div>
<div class="nav-sep" aria-hidden="true" />
+32 -8
View File
@@ -3,6 +3,7 @@ export type NavHubKey =
| 'sales'
| 'activities'
| 'support'
| 'automations'
| 'tools'
| 'settings';
@@ -15,6 +16,8 @@ export type NavIconName =
| 'document'
| 'box'
| 'mail'
| 'send'
| 'sparkle'
| 'dollar'
| 'target'
| 'check'
@@ -190,6 +193,35 @@ export const NAV_HUBS: NavHubDef[] = [
},
],
},
{
key: 'automations',
title: 'اتوماسیون',
desc: 'قوانین خودکار برای گردش کار و پیامک',
icon: 'sparkle',
tint: 'violet',
shortcuts: [
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' },
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' },
],
cards: [
{
to: '/settings/automations',
title: 'اتوماسیون گردش کار',
desc: 'اگر این شد، آن کارها را پشت‌سرهم انجام بده',
icon: 'layers',
tint: 'violet',
admin: true,
},
{
to: '/settings/message-automations',
title: 'اتوماسیون پیامک‌ها',
desc: 'پیامک و ناتیف با تأخیر و تکرار',
icon: 'send',
tint: 'sky',
admin: true,
},
],
},
{
key: 'tools',
title: 'ابزارها',
@@ -292,14 +324,6 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'lightbulb',
tint: 'rose',
},
{
to: '/settings/message-automations',
title: 'پیام خودکار',
desc: 'پیامک و ناتیف با زمان‌بندی تکرار',
icon: 'mail',
tint: 'sky',
admin: true,
},
],
},
];
@@ -282,7 +282,7 @@ watch(trigger, (next) => {
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>اتوماسیون</h1>
<h1>اتوماسیون گردش کار</h1>
<p class="lead">
مثل یک بوم کاری: گره «اگر» را به چند عمل پشت‌سرهم وصل کنید.
</p>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -2314,6 +2314,16 @@ export const MESSAGE_AUTOMATION_UNIT_LABELS: Record<
months: "ماه",
};
export const MESSAGE_AUTOMATION_FIELDS = [
{ token: "{نام}", label: "نام مشتری" },
{ token: "{شماره}", label: "شماره موبایل" },
{ token: "{تاریخ}", label: "تاریخ ارسال" },
{ token: "{عنوان}", label: "عنوان رکورد" },
{ token: "{شرکت}", label: "نام شرکت" },
] as const;
export const MESSAGE_AUTOMATION_SMS_SEGMENT = 70;
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
MessageAutomationChannel,
string
@@ -2345,6 +2355,7 @@ export interface MessageAutomationRuleDto {
notifyTarget: MessageAutomationTarget;
channel: MessageAutomationChannel;
title: string;
description: string | null;
body: string;
createdAt: string;
updatedAt: string;
@@ -2362,6 +2373,7 @@ export interface CreateMessageAutomationRuleRequest {
notifyTarget?: MessageAutomationTarget;
channel?: MessageAutomationChannel;
title: string;
description?: string | null;
body: string;
}