Files
CRM_Montzar/apps/web/src/components/SmsComposeModal.vue
T
MahdiandCursor e04aa14b84 Add org-scoped reusable SMS templates in the compose modal.
Users can save, pick, and delete prepared messages shared across the organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 14:15:42 +03:30

655 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type {
OutboundSmsHistoryResponse,
OutboundSmsLogDto,
SmsTemplateDto,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_FIELDS,
SMS_TEMPLATE_MAX,
SMS_TEMPLATE_NAME_MAX,
countSmsParts,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
const props = withDefaults(
defineProps<{
open: boolean;
endpoint: string;
historyEndpoint?: string;
name: string;
phone: string;
recordTitle?: string;
company?: string;
title?: string;
defaultText?: string;
nest?: boolean;
}>(),
{
historyEndpoint: '',
recordTitle: '',
company: '',
title: 'ارسال پیامک',
defaultText: 'سلام {نام} عزیز',
nest: false,
},
);
const emit = defineEmits<{
close: [];
sent: [];
}>();
const text = ref('');
const busy = ref(false);
const error = ref('');
const done = ref('');
const fieldsOpen = ref(false);
const templatesOpen = ref(false);
const savingTemplate = ref(false);
const templateName = ref('');
const bodyEl = ref<HTMLTextAreaElement | null>(null);
const history = ref<OutboundSmsLogDto[]>([]);
const historyLoading = ref(false);
const templates = ref<SmsTemplateDto[]>([]);
const templatesLoading = ref(false);
const preview = computed(() => fill(text.value));
const count = computed(() => text.value.length);
const parts = computed(() => countSmsParts(text.value));
const canSaveTemplate = computed(
() =>
text.value.trim().length >= 2 &&
templateName.value.trim().length >= 1 &&
templates.value.length < SMS_TEMPLATE_MAX &&
!savingTemplate.value,
);
watch(
() => props.open,
(open) => {
if (!open) return;
text.value = props.defaultText;
error.value = '';
done.value = '';
fieldsOpen.value = false;
templatesOpen.value = false;
templateName.value = '';
busy.value = false;
void loadHistory();
void loadTemplates();
},
);
async function loadHistory() {
history.value = [];
if (!props.historyEndpoint) return;
historyLoading.value = true;
try {
const { data } = await api.get<OutboundSmsHistoryResponse>(props.historyEndpoint, {
params: { limit: 50 },
});
history.value = data.items ?? [];
} catch {
history.value = [];
} finally {
historyLoading.value = false;
}
}
async function loadTemplates() {
templatesLoading.value = true;
try {
const { data } = await api.get<SmsTemplateDto[]>('/sms-templates');
templates.value = Array.isArray(data) ? data : [];
} catch {
templates.value = [];
} finally {
templatesLoading.value = false;
}
}
function fill(source: string) {
const map: Record<string, string> = {
نام: props.name,
name: props.name,
شماره: props.phone,
phone: props.phone,
تاریخ: formatJalaliDay(new Date()),
date: formatJalaliDay(new Date()),
عنوان: props.recordTitle || props.name,
title: props.recordTitle || props.name,
شرکت: props.company,
company: props.company,
};
return source.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()];
return next === undefined ? full : next;
});
}
function insertField(token: string) {
const el = bodyEl.value;
const current = text.value;
if (!el) {
text.value = `${current}${token}`;
} else {
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? start;
text.value = `${current.slice(0, start)}${token}${current.slice(end)}`;
const caret = start + token.length;
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(caret, caret);
});
}
fieldsOpen.value = false;
}
function toggleFields() {
fieldsOpen.value = !fieldsOpen.value;
if (fieldsOpen.value) templatesOpen.value = false;
}
function toggleTemplates() {
templatesOpen.value = !templatesOpen.value;
if (templatesOpen.value) {
fieldsOpen.value = false;
void loadTemplates();
}
}
function applyTemplate(item: SmsTemplateDto) {
text.value = item.body;
templatesOpen.value = false;
error.value = '';
}
async function saveTemplate() {
const name = templateName.value.trim().slice(0, SMS_TEMPLATE_NAME_MAX);
const body = text.value.trim();
if (!canSaveTemplate.value || !name || body.length < 2) return;
savingTemplate.value = true;
error.value = '';
try {
const { data } = await api.post<SmsTemplateDto[]>('/sms-templates', { name, body });
templates.value = Array.isArray(data) ? data : [];
templateName.value = '';
} catch (e) {
error.value = apiMessage(e, 'ذخیره پیامک آماده ناموفق بود.');
} finally {
savingTemplate.value = false;
}
}
async function removeTemplate(item: SmsTemplateDto) {
if (!confirm(`پیامک آماده «${item.name}» حذف شود؟`)) return;
error.value = '';
try {
const { data } = await api.delete<SmsTemplateDto[]>(`/sms-templates/${item.id}`);
templates.value = Array.isArray(data) ? data : [];
} catch (e) {
error.value = apiMessage(e, 'حذف پیامک آماده ناموفق بود.');
}
}
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) return fallback;
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ') || fallback;
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
async function send(force = false) {
if (!props.endpoint || busy.value || done.value) return;
busy.value = true;
error.value = '';
try {
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
done.value = 'پیامک ارسال شد.';
emit('sent');
void loadHistory();
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
if (confirm(msg)) {
busy.value = false;
await send(true);
return;
}
error.value = msg;
} else {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
}
} finally {
busy.value = false;
}
}
function statusLabel(status: OutboundSmsLogDto['status']) {
return status === 'failed' ? 'ناموفق' : 'ارسال‌شده';
}
</script>
<template>
<UiModal :open="open" :nest="nest" :title="title" @close="emit('close')">
<p class="who">
برای {{ name || 'این شخص' }}
<span v-if="phone" dir="ltr">{{ toFaDigits(phone) }}</span>
<span v-else class="warn">شماره موبایل ندارد</span>
</p>
<div class="tools">
<div class="tool-row">
<button type="button" class="pill" :class="{ on: fieldsOpen }" @click="toggleFields">
فیلدهای پویا
</button>
<button
type="button"
class="pill"
:class="{ on: templatesOpen }"
@click="toggleTemplates"
>
پیامک‌های آماده
</button>
</div>
<div v-if="fieldsOpen" class="fields">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</div>
<div v-if="templatesOpen" class="templates">
<p v-if="templatesLoading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!templates.length" class="muted empty">
هنوز پیامک آماده‌ای نیست. متن را بنویسید و ذخیره کنید.
</p>
<ul v-else class="template-list">
<li v-for="item in templates" :key="item.id">
<button type="button" class="pick" @click="applyTemplate(item)">
<strong>{{ item.name }}</strong>
<span>{{ item.body }}</span>
</button>
<button
type="button"
class="del"
:aria-label="`حذف ${item.name}`"
@click="removeTemplate(item)"
>
حذف
</button>
</li>
</ul>
<form class="save-row" @submit.prevent="saveTemplate">
<input
v-model="templateName"
type="text"
:maxlength="SMS_TEMPLATE_NAME_MAX"
placeholder="نام پیامک آماده"
autocomplete="off"
/>
<button type="submit" :disabled="!canSaveTemplate">
{{ savingTemplate ? '…' : 'ذخیره متن فعلی' }}
</button>
</form>
<p v-if="templates.length >= SMS_TEMPLATE_MAX" class="muted">
سقف {{ toFaDigits(SMS_TEMPLATE_MAX) }} قالب پر شده است.
</p>
</div>
</div>
<textarea ref="bodyEl" v-model="text" rows="4" maxlength="500" placeholder="سلام {نام} عزیز" />
<p class="count">{{ toFaDigits(count) }} نویسه · {{ toFaDigits(parts) }} پیامک</p>
<div class="preview">
<span>پیش‌نمایش</span>
<p>{{ preview || 'متن پیام هنوز نوشته نشده است.' }}</p>
</div>
<div v-if="historyEndpoint" class="history">
<header>
<strong>پیامک‌های قبلی</strong>
<span v-if="historyLoading" class="muted">در حال بارگذاری…</span>
</header>
<p v-if="!historyLoading && !history.length" class="muted empty">
هنوز پیامکی ثبت نشده است.
</p>
<ul v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="meta">
<span class="src">{{ item.sourceLabel }}</span>
<span :class="['st', item.status]">{{ statusLabel(item.status) }}</span>
<time>{{ formatJalaliNumeric(new Date(item.createdAt)) }}</time>
</div>
<p>{{ item.body }}</p>
</li>
</ul>
</div>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="done" class="ok">{{ done }}</p>
<template #footer>
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
<UiButton
variant="primary"
:loading="busy"
:disabled="busy || !!done || !phone || text.trim().length < 2"
@click="send"
>
ارسال پیامک
</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.who {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.7rem;
color: #0f172a;
}
.who span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #64748b;
font-size: 0.85rem;
}
.warn {
color: #b45309;
}
.tools {
position: relative;
margin-bottom: 0.45rem;
}
.tool-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pill,
.fields button {
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
background: var(--accent-subtle);
color: var(--accent-deep);
border-radius: 999px;
font: inherit;
cursor: pointer;
}
.pill {
padding: 0.28rem 0.7rem;
font-size: 0.8rem;
}
.pill.on {
background: var(--accent-deep);
color: #fff;
border-color: var(--accent-deep);
}
.fields,
.templates {
position: absolute;
z-index: 2;
top: calc(100% + 0.3rem);
inset-inline: 0;
display: grid;
gap: 0.25rem;
max-width: 100%;
padding: 0.4rem;
background: #fff;
border: 1px solid #e7eef2;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.fields button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border-radius: 8px;
padding: 0.35rem 0.5rem;
background: #fff;
text-align: start;
}
.fields code {
color: var(--accent-deep);
}
.template-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
max-height: 11rem;
overflow: auto;
}
.template-list li {
display: flex;
align-items: stretch;
gap: 0.3rem;
}
.template-list .pick {
flex: 1;
min-width: 0;
display: grid;
gap: 0.15rem;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.5rem;
background: #f8fafc;
text-align: start;
font: inherit;
cursor: pointer;
color: #0f172a;
}
.template-list .pick:hover {
background: #f0fdfa;
}
.template-list .pick strong {
font-size: 0.82rem;
}
.template-list .pick span {
color: #64748b;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-list .del {
flex: none;
border: 0;
border-radius: 8px;
background: transparent;
color: #b91c1c;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
padding: 0.35rem 0.45rem;
}
.save-row {
display: flex;
gap: 0.3rem;
margin-top: 0.2rem;
}
.save-row input {
flex: 1;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
font: inherit;
}
.save-row button {
flex: none;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #fff;
font: inherit;
font-size: 0.78rem;
padding: 0.4rem 0.65rem;
cursor: pointer;
}
.save-row button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.7rem 0.8rem;
font: inherit;
resize: vertical;
color: #0f172a;
}
.count {
margin: 0.35rem 0 0;
color: #94a3b8;
font-size: 0.75rem;
}
.preview {
margin-top: 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: 12px;
background: var(--accent-subtle);
}
.preview span {
color: var(--accent-deep);
font-size: 0.75rem;
}
.preview p {
margin: 0.3rem 0 0;
color: #134e4a;
overflow-wrap: anywhere;
}
.history {
margin-top: 0.9rem;
border-top: 1px solid #eef2f7;
padding-top: 0.75rem;
}
.history header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.history header strong {
font-size: 0.9rem;
color: #0f172a;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: 220px;
overflow: auto;
}
.history-list li {
padding: 0.55rem 0.65rem;
border: 1px solid #eef2f7;
border-radius: 10px;
background: #fafbfc;
}
.history-list .meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.65rem;
align-items: center;
margin-bottom: 0.25rem;
font-size: 0.72rem;
color: #64748b;
}
.history-list .src {
font-weight: 700;
color: #0f766e;
}
.history-list .st.failed {
color: #b91c1c;
}
.history-list .st.sent {
color: #15803d;
}
.history-list p {
margin: 0;
color: #0f172a;
font-size: 0.86rem;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.muted,
.empty {
color: #94a3b8;
font-size: 0.8rem;
margin: 0;
}
.err,
.ok {
margin: 0.55rem 0 0;
font-size: 0.82rem;
}
.err {
color: #dc2626;
}
.ok {
color: #16a34a;
}
</style>