added table of co-woeker requests and new features 3
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-form :model="form" ref="formRef" autocomplete="off">
|
<a-form :model="form" ref="formRef" autocomplete="off">
|
||||||
<div class="space-y-3 md:grid md:grid-cols-4 md:gap-2 md:space-y-0">
|
<div class="space-y-3">
|
||||||
<a-form-item label="انتخاب گروه" class="md:col-span-4">
|
<a-form-item label="انتخاب گروه" class="!mb-0">
|
||||||
<BasicSelect
|
<BasicSelect
|
||||||
v-model:value="selectedGroup"
|
v-model:value="selectedGroup"
|
||||||
:items="groupItems"
|
:items="groupItems"
|
||||||
@@ -12,63 +12,88 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="انتخاب کاربر" class="md:col-span-4 mb-0">
|
<div class="flex flex-col gap-3 md:grid md:grid-cols-3 md:items-stretch md:gap-3">
|
||||||
<BasicInput
|
<a-form-item label="انتخاب کاربر" class="!mb-0 md:col-span-2">
|
||||||
place-holder="جستجو"
|
<BasicInput
|
||||||
class="mb-2"
|
place-holder="جستجو نام یا شماره"
|
||||||
@onchange="filterNumbers"
|
class="mb-2"
|
||||||
:disabled="selectedGroup.length === 0"
|
@onchange="filterNumbers"
|
||||||
/>
|
:disabled="selectedGroup.length === 0"
|
||||||
|
/>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
class="hidden md:block"
|
class="hidden md:block"
|
||||||
rowKey="mobile"
|
rowKey="mobile"
|
||||||
:row-selection="{ selectedRowKeys: form.numbers, onChange: onSelectChange }"
|
:row-selection="rowSelection"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:scroll="{ y: '20vh', x: 500 }"
|
:scroll="{ y: 280, x: 500 }"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
:data-source="groupNumbers"
|
:data-source="groupNumbers"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="md:hidden max-h-48 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50 p-2 space-y-1"
|
class="md:hidden max-h-48 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50 p-2 space-y-1"
|
||||||
>
|
|
||||||
<div v-if="customersLoading" class="flex justify-center py-6">
|
|
||||||
<a-spin size="small" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!groupNumbers.length" class="py-6 text-center text-sm text-gray-500">
|
|
||||||
کاربری یافت نشد
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
v-for="customer in groupNumbers"
|
|
||||||
:key="customer.mobile"
|
|
||||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-white px-3 py-2 active:bg-gray-100"
|
|
||||||
:class="isSelected(customer.mobile) ? 'border-blue-200 bg-blue-50' : ''"
|
|
||||||
>
|
>
|
||||||
<a-checkbox
|
<div v-if="customersLoading" class="flex justify-center py-6">
|
||||||
:checked="isSelected(customer.mobile)"
|
<a-spin size="small" />
|
||||||
@change="(e) => toggleCustomer(customer.mobile, e.target.checked)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-sm font-600 text-gray-900">{{ customer.name || '-' }}</div>
|
|
||||||
<div class="truncate text-xs text-gray-500">{{ customer.mobile }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-gray-400">#{{ customer.id }}</span>
|
<div v-else-if="!groupNumbers.length" class="py-6 text-center text-sm text-gray-500">
|
||||||
</label>
|
کاربری یافت نشد
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
<label
|
||||||
|
v-for="customer in groupNumbers"
|
||||||
|
:key="customer.mobile"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-white px-3 py-2 active:bg-gray-100"
|
||||||
|
:class="isSelected(customer.mobile) ? 'border-blue-200 bg-blue-50' : ''"
|
||||||
|
>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="isSelected(customer.mobile)"
|
||||||
|
@change="(e) => toggleCustomer(customer.mobile, e.target.checked)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-600 text-gray-900">{{ customer.name || '-' }}</div>
|
||||||
|
<div class="truncate text-xs text-gray-500">{{ customer.mobile }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-xs text-gray-400">#{{ customer.id }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="متن پیام" name="message" class="md:col-span-4 mb-0">
|
<a-form-item label="متن پیام" name="message" class="!mb-0 md:col-span-1" :rules="messageRules">
|
||||||
<BasicTextArea v-model:value="form.message" />
|
<a-textarea
|
||||||
<div class="mt-2 space-y-2 md:flex md:items-center md:justify-between md:flex-row-reverse md:space-y-0">
|
ref="messageAreaRef"
|
||||||
<BaseButton type="primary" class="w-full md:w-10rem" @click="addName">افزودن نام کاربر</BaseButton>
|
v-model:value="form.message"
|
||||||
<p class="text-xs leading-5 text-gray-500 md:max-w-[70%]">
|
class="sms-message-area"
|
||||||
برای استفاده از نام اشخاص در پیام از گزینه افزودن نام کاربر استفاده کنید. این متغیر با
|
:rows="10"
|
||||||
عنوان (%name) به متن پیام اضافه میگردد.
|
placeholder="متن پیام را بنویسید…"
|
||||||
</p>
|
@blur="rememberCursor"
|
||||||
</div>
|
@click="rememberCursor"
|
||||||
</a-form-item>
|
@keyup="rememberCursor"
|
||||||
|
@select="rememberCursor"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span class="font-600 text-gray-800">پایان پیام (اجباری):</span>
|
||||||
|
<span class="ms-1">{{ appDisplayName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-col gap-2">
|
||||||
|
<BasicButton type="primary" class="!w-full" @mousedown.prevent="addName">
|
||||||
|
افزودن نام کاربر
|
||||||
|
</BasicButton>
|
||||||
|
<p class="m-0 text-xs leading-5 text-gray-500">
|
||||||
|
با زدن دکمه، در محل کرسر توکن
|
||||||
|
<bdi class="font-700">%نام</bdi>
|
||||||
|
اضافه میشود. معادلها:
|
||||||
|
<bdi>%name</bdi>،
|
||||||
|
<bdi>٪نام</bdi>،
|
||||||
|
<bdi>% نام</bdi>
|
||||||
|
هم پذیرفته میشوند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-form>
|
</a-form>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,6 +104,10 @@ import type { FunctionItem } from '@/model/Item'
|
|||||||
import type { Customer } from '@/model/customer'
|
import type { Customer } from '@/model/customer'
|
||||||
import type { Sms } from '@/model/sms'
|
import type { Sms } from '@/model/sms'
|
||||||
import { getCustomerList } from '@/api/sms'
|
import { getCustomerList } from '@/api/sms'
|
||||||
|
import { getConfiguration } from '@/api/configuration'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
const NAME_TOKEN = '%نام'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: Sms
|
form: Sms
|
||||||
@@ -87,10 +116,14 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
const messageAreaRef = ref<{ $el?: HTMLElement } | null>(null)
|
||||||
const selectedGroup = ref<string[]>([])
|
const selectedGroup = ref<string[]>([])
|
||||||
const groupNumbers = ref<Customer[]>([])
|
const groupNumbers = ref<Customer[]>([])
|
||||||
const customersLoading = ref(false)
|
const customersLoading = ref(false)
|
||||||
|
const appName = ref('')
|
||||||
let baseGroupNumbers: Customer[] = []
|
let baseGroupNumbers: Customer[] = []
|
||||||
|
let cursorStart = 0
|
||||||
|
let cursorEnd = 0
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'کد کاربر', dataIndex: 'id' },
|
{ title: 'کد کاربر', dataIndex: 'id' },
|
||||||
@@ -98,16 +131,91 @@ const columns = [
|
|||||||
{ title: 'شماره همراه', dataIndex: 'mobile' }
|
{ title: 'شماره همراه', dataIndex: 'mobile' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const appDisplayName = computed(() => appName.value.trim() || 'اپ')
|
||||||
|
|
||||||
|
const messageRules = [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: async (_: unknown, value: string) => {
|
||||||
|
if (!String(value ?? '').trim()) {
|
||||||
|
return Promise.reject('متن پیام خالی است')
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
selectedRowKeys: props.form.numbers ?? [],
|
||||||
|
onChange: onSelectChange,
|
||||||
|
preserveSelectedRowKeys: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadAppName()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAppName() {
|
||||||
|
try {
|
||||||
|
const rows = await getConfiguration()
|
||||||
|
const list = Array.isArray(rows) ? rows : []
|
||||||
|
appName.value = String(list.find((r) => r.key === 'name_fa')?.value ?? '').trim()
|
||||||
|
} catch {
|
||||||
|
appName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextareaEl(): HTMLTextAreaElement | null {
|
||||||
|
const root = messageAreaRef.value?.$el
|
||||||
|
if (!root) return null
|
||||||
|
if (root instanceof HTMLTextAreaElement) return root
|
||||||
|
return root.querySelector?.('textarea') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberCursor() {
|
||||||
|
const ta = getTextareaEl()
|
||||||
|
if (!ta) return
|
||||||
|
cursorStart = ta.selectionStart ?? 0
|
||||||
|
cursorEnd = ta.selectionEnd ?? cursorStart
|
||||||
|
}
|
||||||
|
|
||||||
function addName() {
|
function addName() {
|
||||||
props.form.message = (props.form.message ?? '').concat('', '%name')
|
rememberCursor()
|
||||||
|
const text = props.form.message ?? ''
|
||||||
|
const start = cursorStart
|
||||||
|
const end = cursorEnd
|
||||||
|
props.form.message = text.slice(0, start) + NAME_TOKEN + text.slice(end)
|
||||||
|
const nextPos = start + NAME_TOKEN.length
|
||||||
|
cursorStart = nextPos
|
||||||
|
cursorEnd = nextPos
|
||||||
|
nextTick(() => {
|
||||||
|
const ta = getTextareaEl()
|
||||||
|
if (!ta) return
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(nextPos, nextPos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نام اپ را بهصورت خط آخر اجباری میچسباند */
|
||||||
|
function buildFinalMessage(): string {
|
||||||
|
const body = String(props.form.message ?? '').trim()
|
||||||
|
const app = appDisplayName.value
|
||||||
|
if (!body) return app
|
||||||
|
const lines = body.split(/\r?\n/)
|
||||||
|
const last = (lines[lines.length - 1] ?? '').trim()
|
||||||
|
if (last === app) return body
|
||||||
|
return `${body}\n${app}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterNumbers(val: string) {
|
function filterNumbers(val: string) {
|
||||||
if (val !== '') {
|
const q = String(val ?? '').trim()
|
||||||
groupNumbers.value = baseGroupNumbers.filter((v) => v.name?.includes(val))
|
if (!q) {
|
||||||
} else {
|
|
||||||
groupNumbers.value = baseGroupNumbers
|
groupNumbers.value = baseGroupNumbers
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
groupNumbers.value = baseGroupNumbers.filter(
|
||||||
|
(v) => v.name?.includes(q) || String(v.mobile ?? '').includes(q)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroupNumbers(val: string[]) {
|
async function loadGroupNumbers(val: string[]) {
|
||||||
@@ -123,7 +231,6 @@ async function loadGroupNumbers(val: string[]) {
|
|||||||
const { customers } = await getCustomerList(val)
|
const { customers } = await getCustomerList(val)
|
||||||
groupNumbers.value = customers
|
groupNumbers.value = customers
|
||||||
baseGroupNumbers = customers
|
baseGroupNumbers = customers
|
||||||
// فقط شمارههای همین لیست مشتری بمانند
|
|
||||||
const allowed = new Set(customers.map((c) => c.mobile).filter(Boolean) as string[])
|
const allowed = new Set(customers.map((c) => c.mobile).filter(Boolean) as string[])
|
||||||
props.form.numbers = (props.form.numbers ?? []).filter((n) => allowed.has(n))
|
props.form.numbers = (props.form.numbers ?? []).filter((n) => allowed.has(n))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -133,9 +240,22 @@ async function loadGroupNumbers(val: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectChange(changableRowKeys: string[]) {
|
/**
|
||||||
const allowed = new Set(baseGroupNumbers.map((c) => c.mobile).filter(Boolean) as string[])
|
* تیکهای خارج از نتیجهٔ سرچ فعلی حفظ میشوند؛
|
||||||
props.form.numbers = changableRowKeys.filter((n) => allowed.has(n))
|
* فقط وضعیت ردیفهای قابلمشاهده از selectedRowKeys بهروز میشود.
|
||||||
|
*/
|
||||||
|
function onSelectChange(selectedRowKeys: string[]) {
|
||||||
|
const visible = new Set(
|
||||||
|
groupNumbers.value.map((c) => c.mobile).filter(Boolean) as string[]
|
||||||
|
)
|
||||||
|
const selectedVisible = new Set(selectedRowKeys)
|
||||||
|
const next = new Set((props.form.numbers ?? []).filter((m) => !visible.has(m)))
|
||||||
|
for (const mobile of selectedVisible) {
|
||||||
|
if (baseGroupNumbers.some((c) => c.mobile === mobile)) {
|
||||||
|
next.add(mobile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props.form.numbers = Array.from(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(mobile?: string) {
|
function isSelected(mobile?: string) {
|
||||||
@@ -162,10 +282,26 @@ function resetAll() {
|
|||||||
groupNumbers.value = []
|
groupNumbers.value = []
|
||||||
baseGroupNumbers = []
|
baseGroupNumbers = []
|
||||||
props.form.numbers = []
|
props.form.numbers = []
|
||||||
|
props.form.message = ''
|
||||||
|
cursorStart = 0
|
||||||
|
cursorEnd = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
validateFields: () => formRef.value?.validateFields(),
|
validateFields: () => formRef.value?.validateFields(),
|
||||||
resetAll
|
resetAll,
|
||||||
|
buildFinalMessage
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sms-message-area {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sms-message-area {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const table = ref<null | { fetch: () => void }>(null)
|
|||||||
const smsEditorRef = ref<{
|
const smsEditorRef = ref<{
|
||||||
validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>>
|
validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>>
|
||||||
resetAll?: () => void
|
resetAll?: () => void
|
||||||
|
buildFinalMessage?: () => string
|
||||||
}>()
|
}>()
|
||||||
const sendStore = useGroupSmsSendStore()
|
const sendStore = useGroupSmsSendStore()
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ function ok() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = form.value.message
|
const text = smsEditorRef.value?.buildFinalMessage?.() ?? form.value.message
|
||||||
addSMSVisible.value = false
|
addSMSVisible.value = false
|
||||||
resetData()
|
resetData()
|
||||||
await sendStore.start(numbers, text)
|
await sendStore.start(numbers, text)
|
||||||
|
|||||||
Reference in New Issue
Block a user