added table

This commit is contained in:
2026-08-16 20:01:54 +03:30
parent 27f3a6212a
commit 609f6b2fa3
17 changed files with 1227 additions and 39 deletions
+1
View File
@@ -88,6 +88,7 @@ declare module '@vue/runtime-core' {
BudgetSelect: typeof import('./src/components/BudgetSelect.vue')['default']
CustomerImportProgress: typeof import('./src/components/CustomerImportProgress.vue')['default']
CustomTag: typeof import('./src/components/CustomTag.vue')['default']
GroupSmsSendProgress: typeof import('./src/components/GroupSmsSendProgress.vue')['default']
PargarRetryModal: typeof import('./src/components/PargarRetryModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+2
View File
@@ -2,6 +2,7 @@
<AConfigProvider direction="rtl">
<RouterView />
<CustomerImportProgress />
<GroupSmsSendProgress />
</AConfigProvider>
</template>
@@ -11,6 +12,7 @@ import { message } from 'ant-design-vue'
import { useConfigStore } from '@/store/configStore'
import { initDarkmode } from '@core/stores/darkmode'
import CustomerImportProgress from '@/components/CustomerImportProgress.vue'
import GroupSmsSendProgress from '@/components/GroupSmsSendProgress.vue'
import { useUserStore } from '@/store/userStore'
import { bootstrapAdminChat, useAdminChatService } from '@/utils/adminChat'
import { supportChatMenuEnabledSetting } from '@/utils/config'
+3
View File
@@ -6,6 +6,9 @@ import type { AddId, Customer } from '@/model/customer'
export const getOrderTable = async (params: TableInput) =>
await useAxios.post<Array<Order>>('/admin/order/table', params)
export const getColleagueOrderTable = async (params: TableInput) =>
await useAxios.post<TableOutput<Order>>('/admin/order/colleague/table', params)
export const createOrder = async (obj: Order) =>
await useAxios.post<AddId>(`/admin/single/order`, obj, { meta: { silent: true } })
+714
View File
@@ -0,0 +1,714 @@
<template>
<Teleport to="body">
<Transition name="gsp-fade">
<div
v-if="store.visible && !store.minimized"
class="gsp-overlay"
@click.self="store.active ? store.minimize() : null"
>
<div class="gsp-modal" :class="store.finished ? `gsp-modal--${outcomeInfo.tone}` : null">
<div class="gsp-modal__head">
<div class="gsp-modal__title">
<Icon :icon="headIcon" :size="18" />
<span>{{ headTitle }}</span>
</div>
<div class="gsp-modal__head-actions">
<button
v-if="store.active"
type="button"
class="gsp-icon-btn"
title="کوچک کردن"
@click="store.minimize()"
>
<Icon icon="vuesax-linear:minus" :size="18" />
</button>
<button
v-else
type="button"
class="gsp-icon-btn"
title="بستن"
@click="store.dismiss()"
>
<Icon icon="vuesax-linear:close-circle" :size="18" />
</button>
</div>
</div>
<div v-if="store.active" class="gsp-modal__body">
<a-alert
class="gsp-warn"
type="warning"
show-icon
message="پنل را نبندید و صفحه را رفرش نکنید؛ ارسال قطع می‌شود."
/>
<div class="gsp-counter">
<span class="gsp-counter__done">{{ store.processed }}</span>
<span class="gsp-counter__sep">/</span>
<span class="gsp-counter__total">{{ store.total }}</span>
</div>
<div class="gsp-counter__label">در حال ارسال پیامک گروهی (۱۰تایی)…</div>
<div class="gsp-track">
<div class="gsp-track__fill" :style="{ width: store.percent + '%' }" />
</div>
<div class="gsp-percent">{{ store.percent }}٪</div>
<div class="gsp-current">
<a-spin size="small" />
<span class="gsp-current__name">{{ store.currentLabel || '...' }}</span>
</div>
<div class="gsp-stats">
<div class="gsp-stat gsp-stat--ok">
<Icon icon="vuesax-linear:tick-circle" :size="14" />
{{ store.success }} موفق
</div>
<div v-if="store.failed" class="gsp-stat gsp-stat--fail">
<Icon icon="vuesax-linear:close-circle" :size="14" />
{{ store.failed }} ناموفق
</div>
</div>
</div>
<div v-else class="gsp-modal__body gsp-report">
<div class="gsp-report__hero" :class="`gsp-report__hero--${outcomeInfo.tone}`">
<Icon :icon="outcomeInfo.icon" :size="34" />
</div>
<div class="gsp-report__title">{{ outcomeInfo.text }}</div>
<div class="gsp-report__subtitle">
از مجموع {{ store.total }} شماره، {{ store.processed }} مورد پردازش شد
</div>
<div class="gsp-report__cards">
<div class="gsp-rcard gsp-rcard--ok">
<Icon icon="vuesax-linear:tick-circle" :size="20" />
<div class="gsp-rcard__num">{{ store.success }}</div>
<div class="gsp-rcard__label">با موفقیت ارسال شد</div>
</div>
<div class="gsp-rcard" :class="store.failed ? 'gsp-rcard--fail' : 'gsp-rcard--muted'">
<Icon icon="vuesax-linear:close-circle" :size="20" />
<div class="gsp-rcard__num">{{ store.failed }}</div>
<div class="gsp-rcard__label">ناموفق / خطا</div>
</div>
</div>
<div v-if="store.errors.length" class="gsp-report__errors">
<div class="gsp-report__errors-head">
<Icon icon="vuesax-linear:close-circle" :size="14" />
جزئیات دسته‌های ناموفق ({{ store.errors.length }})
</div>
<div class="gsp-report__errors-list">
<div v-for="e in store.errors" :key="`b-${e.batch}`" class="gsp-erow">
<span class="gsp-erow__name">دسته {{ e.batch }} ({{ e.numbers.length }} شماره)</span>
<span class="gsp-erow__msg">{{ e.error }}</span>
</div>
</div>
</div>
</div>
<div class="gsp-modal__foot">
<a-button v-if="store.active" danger size="small" @click="store.cancel()">
توقف
</a-button>
<a-button v-if="store.active" size="small" @click="store.minimize()">
کوچک کردن
</a-button>
<a-button v-if="store.finished" type="primary" size="small" @click="store.dismiss()">
بستن گزارش
</a-button>
</div>
</div>
</div>
</Transition>
<Transition name="gsp-slide">
<button
v-if="store.visible && store.minimized"
type="button"
class="gsp-mini"
:class="store.finished ? `gsp-mini--${outcomeInfo.tone}` : null"
@click="store.expand()"
>
<span class="gsp-mini__ring">
<svg viewBox="0 0 36 36" class="gsp-mini__svg">
<circle class="gsp-mini__bg" cx="18" cy="18" r="15.5" />
<circle class="gsp-mini__fg" cx="18" cy="18" r="15.5" :style="ringStyle" />
</svg>
<span class="gsp-mini__pct">
<template v-if="store.finished">
<Icon :icon="outcomeInfo.icon" :size="16" />
</template>
<template v-else>{{ store.percent }}</template>
</span>
</span>
<span class="gsp-mini__text">
<span class="gsp-mini__title">
<template v-if="store.finished">{{ outcomeInfo.text }}</template>
<template v-else>ارسال پیامک گروهی</template>
</span>
<span class="gsp-mini__count">
<template v-if="store.finished">{{ store.success }} موفق · {{ store.failed }} خطا</template>
<template v-else>{{ store.processed }} از {{ store.total }} · پنل را نبندید</template>
</span>
</span>
<span class="gsp-mini__bar">
<span class="gsp-mini__bar-fill" :style="{ width: store.percent + '%' }" />
</span>
</button>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { useGroupSmsSendStore } from '@/store/groupSmsSendStore'
const store = useGroupSmsSendStore()
const OUTCOME_MAP = {
success: {
icon: 'vuesax-linear:tick-circle',
text: 'ارسال پیامک تکمیل شد',
tone: 'ok' as const
},
error: {
icon: 'vuesax-linear:close-circle',
text: 'ارسال با خطا مواجه شد',
tone: 'fail' as const
},
partial: {
icon: 'vuesax-linear:danger',
text: 'بعضی دسته‌ها با خطا مواجه شد',
tone: 'warn' as const
},
canceled: {
icon: 'vuesax-linear:info-circle',
text: 'ارسال متوقف شد',
tone: 'warn' as const
},
running: {
icon: 'vuesax-linear:sms',
text: 'ارسال پیامک گروهی',
tone: 'ok' as const
}
}
const outcomeInfo = computed(() => OUTCOME_MAP[store.outcome])
const headIcon = computed(() => (store.finished ? outcomeInfo.value.icon : 'vuesax-linear:sms'))
const headTitle = computed(() =>
store.finished ? 'گزارش ارسال پیامک گروهی' : 'ارسال پیامک گروهی'
)
const RING_CIRC = 2 * Math.PI * 15.5
const ringStyle = computed(() => ({
strokeDasharray: `${RING_CIRC}`,
strokeDashoffset: `${RING_CIRC * (1 - store.percent / 100)}`
}))
function onBeforeUnload(e: BeforeUnloadEvent) {
if (!store.active) return
e.preventDefault()
e.returnValue = ''
}
watch(
() => store.active,
(active) => {
if (active) window.addEventListener('beforeunload', onBeforeUnload)
else window.removeEventListener('beforeunload', onBeforeUnload)
},
{ immediate: true }
)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
</script>
<style scoped lang="less">
.gsp-overlay {
position: fixed;
inset: 0;
z-index: 2200;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(2px);
}
.gsp-modal {
width: 100%;
max-width: 420px;
max-height: min(90vh, 640px);
display: flex;
flex-direction: column;
border-radius: 16px;
background: var(--admin-bg-surface);
border: 1px solid var(--admin-border-strong);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 55%, #6366f1 100%);
color: #fff;
flex-shrink: 0;
}
&--ok .gsp-modal__head {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
}
&--warn .gsp-modal__head {
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
}
&--fail .gsp-modal__head {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
&__head-actions {
display: flex;
gap: 4px;
}
&__body {
padding: 20px 18px 8px;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
min-height: 0;
flex: 1;
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 16px;
flex-shrink: 0;
}
}
.gsp-warn {
width: 100%;
margin-bottom: 14px;
}
.gsp-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.18);
color: #fff;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.32);
}
}
.gsp-counter {
display: flex;
align-items: baseline;
gap: 4px;
font-weight: 800;
line-height: 1;
&__done {
font-size: 38px;
color: #0284c7;
}
&__sep {
font-size: 24px;
color: var(--admin-text-muted);
}
&__total {
font-size: 24px;
color: var(--admin-text-secondary);
}
}
.gsp-counter__label {
margin-top: 6px;
font-size: 13px;
color: var(--admin-text-secondary);
}
.gsp-track {
width: 100%;
height: 9px;
margin-top: 16px;
border-radius: 99px;
background: var(--admin-bg-muted);
overflow: hidden;
&__fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, #0284c7, #6366f1);
transition: width 0.35s ease;
}
}
.gsp-percent {
align-self: flex-end;
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: var(--admin-text-secondary);
}
.gsp-current {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
max-width: 100%;
&__name {
font-size: 12px;
color: var(--admin-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.gsp-stats {
display: flex;
gap: 10px;
margin-top: 14px;
}
.gsp-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
padding: 3px 12px;
border-radius: 99px;
&--ok {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
&--fail {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
}
.gsp-report {
padding-top: 22px;
&__hero {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
&--ok {
background: rgba(16, 185, 129, 0.14);
color: #059669;
}
&--warn {
background: rgba(245, 158, 11, 0.16);
color: #d97706;
}
&--fail {
background: rgba(239, 68, 68, 0.13);
color: #dc2626;
}
}
&__title {
margin-top: 12px;
font-size: 16px;
font-weight: 800;
color: var(--admin-text);
text-align: center;
}
&__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--admin-text-secondary);
text-align: center;
}
&__cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%;
margin-top: 18px;
}
&__errors {
width: 100%;
margin-top: 16px;
}
&__errors-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #dc2626;
margin-bottom: 6px;
}
&__errors-list {
max-height: 160px;
overflow-y: auto;
border-radius: 10px;
border: 1px solid var(--admin-border);
background: var(--admin-bg-muted);
}
}
.gsp-rcard {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 14px 10px;
border-radius: 12px;
border: 1px solid transparent;
&__num {
font-size: 26px;
font-weight: 800;
line-height: 1.1;
}
&__label {
font-size: 11px;
font-weight: 600;
}
&--ok {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.25);
color: #059669;
}
&--fail {
background: rgba(239, 68, 68, 0.09);
border-color: rgba(239, 68, 68, 0.22);
color: #dc2626;
}
&--muted {
background: var(--admin-bg-muted);
border-color: var(--admin-border);
color: var(--admin-text-muted);
}
}
.gsp-erow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid var(--admin-border);
&:last-child {
border-bottom: none;
}
&__name {
font-weight: 700;
color: var(--admin-text);
white-space: nowrap;
}
&__msg {
color: #dc2626;
text-align: left;
flex: 1;
word-break: break-word;
}
}
.gsp-mini {
position: fixed;
top: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 2100;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px 8px 10px;
border: 1px solid var(--admin-border-strong);
border-radius: 99px;
background: var(--admin-bg-surface);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
@media (max-width: 767px) {
top: 58px;
max-width: calc(100vw - 24px);
}
&:hover {
transform: translateX(-50%) translateY(1px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
&__ring {
position: relative;
width: 34px;
height: 34px;
flex-shrink: 0;
}
&__svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
&__bg {
fill: none;
stroke: var(--admin-bg-muted);
stroke-width: 3.5;
}
&__fg {
fill: none;
stroke: #0284c7;
stroke-width: 3.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.35s ease;
}
&__pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
color: #0284c7;
}
&__text {
display: flex;
flex-direction: column;
line-height: 1.3;
min-width: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
color: var(--admin-text);
white-space: nowrap;
}
&__count {
font-size: 11px;
color: var(--admin-text-secondary);
white-space: nowrap;
}
&__bar {
position: absolute;
left: 14px;
right: 14px;
bottom: 3px;
height: 2px;
border-radius: 99px;
background: var(--admin-bg-muted);
overflow: hidden;
}
&__bar-fill {
display: block;
height: 100%;
border-radius: 99px;
background: #0284c7;
transition: width 0.35s ease;
}
&--ok {
.gsp-mini__fg,
.gsp-mini__bar-fill {
stroke: #10b981;
background: #10b981;
}
.gsp-mini__pct {
color: #059669;
}
}
&--warn {
.gsp-mini__fg,
.gsp-mini__bar-fill {
stroke: #f59e0b;
background: #f59e0b;
}
.gsp-mini__pct {
color: #d97706;
}
}
&--fail {
.gsp-mini__fg,
.gsp-mini__bar-fill {
stroke: #ef4444;
background: #ef4444;
}
.gsp-mini__pct {
color: #dc2626;
}
}
}
.gsp-fade-enter-active,
.gsp-fade-leave-active {
transition: opacity 0.3s ease;
}
.gsp-fade-enter-from,
.gsp-fade-leave-to {
opacity: 0;
}
.gsp-slide-enter-active,
.gsp-slide-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.gsp-slide-enter-from,
.gsp-slide-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-12px);
}
</style>
@@ -0,0 +1,28 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="تراکنش همکار">
<div class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" />
</div>
</BasicShowContext>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getColleagueOrderTable } from '@/api/order'
import { getColleagueOrderColumns } from '@/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
const [submit, reload] = useBaseTable()
const tblProps = ref({
columns: getColleagueOrderColumns(),
api: (params: TableInput) => getColleagueOrderTable(params)
})
defineExpose({
fetch: reload
})
</script>
@@ -0,0 +1,134 @@
import type { BaseTableColumn } from '@core/type/baseTable'
import { toJalali } from '@core/utils/useDataTime'
import CustomTag from '@/components/CustomTag.vue'
import { usePrice } from '@core/utils/usePrice'
export function getColleagueOrderColumns(): BaseTableColumn[] {
return [
{
title: 'ردیف',
dataIndex: 'id',
key: 'row',
filterable: false,
sortable: false,
customRender: (record: any) => record.index + 1
},
{
title: 'کد سفارش',
dataIndex: 'id',
key: 'id',
type: 'Number'
},
{
title: 'کد همکار',
dataIndex: 'provider_id',
key: 'provider_id',
type: 'Number',
customRender: ({ record }) => record.provider_id ?? '-'
},
{
title: 'نوع',
dataIndex: 'direction',
key: 'direction',
filterable: false,
customRender: ({ record }) => {
if (record.direction === 2) {
return (
<div class="flex justify-center">
<CustomTag textColor="#C53030" bgColor="#FECACA">
فروش
</CustomTag>
</div>
)
}
return (
<div class="flex justify-center">
<CustomTag textColor="#25855A" bgColor="#E3F4E9">
خرید
</CustomTag>
</div>
)
}
},
{
title: 'مشتری',
dataIndex: 'customer.name',
key: 'customer.name',
type: 'Text'
},
{
title: 'مقدار مشتری (گرم)',
dataIndex: 'customer_amount',
key: 'customer_amount',
type: 'Number',
filterable: false,
customRender: ({ record }) => record.customer_amount ?? record.amount ?? '-'
},
{
title: 'مقدار نزد همکار (گرم)',
dataIndex: 'colleague_amount',
key: 'colleague_amount',
type: 'Number',
filterable: false,
customRender: ({ record }) => record.colleague_amount ?? record.amount ?? '-'
},
{
title: 'کارمزد',
dataIndex: 'service_charge',
key: 'service_charge',
type: 'Number',
customRender: ({ record }) => {
if (record.fee_asset === 'rial') {
return usePrice(record.service_charge_price ?? 0, true, '')
}
const gold = record.service_charge ?? 0
return `${gold} گرم`
}
},
{
title: 'قیمت مشتری (ریال)',
dataIndex: 'price',
key: 'price',
type: 'Text',
customRender: ({ record }) => usePrice(record.price ?? 0, true, '')
},
{
title: 'مبلغ مشتری (ریال)',
dataIndex: 'total_price',
key: 'total_price',
type: 'Text',
customRender: ({ record }) => usePrice(record.total_price ?? 0, true, '')
},
{
title: 'نرخ همکار (ریال/گرم)',
dataIndex: 'colleague_unit_price',
key: 'colleague_unit_price',
type: 'Text',
filterable: false,
customRender: ({ record }) =>
usePrice(record.colleague_unit_price ?? record.total_price_montzar ?? 0, true, '')
},
{
title: 'مبلغ نزد همکار (ریال)',
dataIndex: 'colleague_total_price',
key: 'colleague_total_price',
type: 'Text',
filterable: false,
customRender: ({ record }) => usePrice(record.colleague_total_price ?? 0, true, '')
},
{
title: 'سود کل (ریال)',
dataIndex: 'total_fee',
key: 'total_fee',
type: 'Text',
customRender: ({ record }) => usePrice(record.total_fee ?? 0, true, '')
},
{
title: 'تاریخ',
dataIndex: 'created_at',
key: 'created_at',
type: 'DateTime',
customRender: (record: any) => toJalali(record.record.created_at)
}
]
}
@@ -0,0 +1,16 @@
<template>
<div>
<BasicMainCard>
<div class="flex h-full w-full min-w-0 max-w-full flex-col overflow-hidden">
<tbl ref="table" class="min-h-0 flex-1" />
</div>
</BasicMainCard>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import tbl from './components/table.vue'
const table = ref<InstanceType<typeof tbl> | null>(null)
</script>
+8 -2
View File
@@ -202,7 +202,7 @@ import SettingValueField from './SettingValueField.vue'
import SettingCard from './SettingCard.vue'
import { refreshAppModeMenu } from '@/router'
import { useUserStore } from '@/store/userStore'
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyProductFundsEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applyPriceChannelAccessFromRows, applySupportChatMenuEnabledFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setProductFundsEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setPriceChannelAccessFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings } from '@/utils/config'
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyProductFundsEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applyPriceChannelAccessFromRows, applySupportChatMenuEnabledFromRows, applyGroupSmsFromRows, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setProductFundsEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setPriceChannelAccessFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings, setGroupSmsEnabledFromSettings } from '@/utils/config'
import {
EDITABLE_SETTING_GROUPS,
SYSTEM_SETTING_GROUPS,
@@ -484,11 +484,13 @@ async function initConfig() {
applyPriceProviderFromRows(baseData.value)
applyPriceChannelAccessFromRows(baseData.value)
applySupportChatMenuEnabledFromRows(baseData.value)
applyGroupSmsFromRows(baseData.value)
// در صفحهٔ عادی price_provider و show_to_users_chat و price_channel_access (can_edit=0) نیستند — از کل تنظیمات بخوان
if (
!baseData.value.some((row) => row.key === 'price_provider') ||
!baseData.value.some((row) => row.key === 'show_to_users_chat') ||
!baseData.value.some((row) => row.key === 'price_channel_access')
!baseData.value.some((row) => row.key === 'price_channel_access') ||
!baseData.value.some((row) => row.key === 'group_sms')
) {
try {
const { getConfiguration } = await import('@/api/configuration')
@@ -497,6 +499,7 @@ async function initConfig() {
applyPriceProviderFromRows(rows)
applySupportChatMenuEnabledFromRows(rows)
applyPriceChannelAccessFromRows(rows)
applyGroupSmsFromRows(rows)
} catch {
/* کش قبلی می‌ماند */
}
@@ -566,6 +569,9 @@ function syncMenuFlagsFromSetting(key: string | undefined, value: string) {
if (key === 'price_channel_access') {
setPriceChannelAccessFromSettings(isSwitchOn(value))
}
if (key === 'group_sms') {
setGroupSmsEnabledFromSettings(isSwitchOn(value))
}
}
function onValueChange(row: Const, value: string) {
@@ -155,7 +155,6 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
icon: 'vuesax-linear:notification',
match: (k) =>
k === 'charge_notif' ||
k === 'group_sms' ||
k === 'register_referral_sms_to_referrer' ||
/^sms_notif_/.test(k)
},
@@ -180,10 +179,16 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'دسترسی مشتری به قابلیت‌ها',
icon: 'vuesax-linear:people',
match: (k) =>
/^(enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test(
/^(enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test(
k
)
},
{
id: 'limits',
title: 'محدودیت‌ها',
icon: 'vuesax-linear:lock',
match: (k) => k === 'group_sms'
},
{
id: 'support_chat',
title: 'چت پشتیبانی',
@@ -824,7 +824,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
sms_notif_support_admin:
'روشن: وقتی مشتری اولین پیام خوانده‌نشده را در چت پشتیبانی می‌فرستد و ادمین همان گفتگو را باز و visible ندارد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان پیامک چت نمی‌رود.',
group_sms:
'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است. خاموش: ارسال پیامک گروهی غیرفعال است.',
'روشن: دکمه ارسال پیامک گروهی در پنل ادمین فعال است (ارسال ۱۰تایی). خاموش: ارسال پیامک گروهی غیرفعال است. فقط از تنظیمات پیشرفته.',
profit_gold_api_enabled:
'روشن: محاسبه سود طلا از API خارجی فعال است. خاموش: محاسبه سود از API خارجی انجام نمی‌شود (حالت پیش‌فرض اکثر نصب‌ها).',
main_wallet_products:
+7 -1
View File
@@ -24,7 +24,12 @@ import BasicMenu from '@/components/BasicMenu/index.vue'
import { useRouterStore } from '@core/stores/router'
import { get } from 'lodash'
import type { RouteRecordRaw } from 'vue-router'
import { conditionalOrderEnabledSetting, initWalletSettingsFromApi, priceTriggerEnabledSetting } from '@/utils/config'
import {
conditionalOrderEnabledSetting,
initWalletSettingsFromApi,
priceProviderSetting,
priceTriggerEnabledSetting
} from '@/utils/config'
const routerStore = useRouterStore()
const route = useRoute()
@@ -40,6 +45,7 @@ const menu = computed(() => {
.filter((child) => !child.meta?.hideMenu)
.filter((child) => child.name !== 'conditionalOrders' || conditionalOrderEnabledSetting.value)
.filter((child) => child.name !== 'priceTriggers' || priceTriggerEnabledSetting.value)
.filter((child) => child.name !== 'colleagueOrdersMain' || priceProviderSetting.value === 'montzar')
.sort((a, b) => (Number(a.meta?.order) || 99) - (Number(b.meta?.order) || 99))
})
</script>
@@ -42,13 +42,13 @@
rowKey="mobile"
:row-selection="{ selectedRowKeys: form.numbers, onChange: onSelectChange }"
:columns="columns"
:scroll="{ y: '20vh', x: 500 }"
:scroll="{ y: 280, x: 500 }"
:pagination="false"
:data-source="groupNumbers"
/>
<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-64 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" />
+18 -12
View File
@@ -1,21 +1,21 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست پیامک ها">
<!-- <template v-if="!mobile && isMobileScreen" #extra>
<template v-if="groupSmsEnabled && !mobile && isMobileScreen" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:sms" @click="emits('add')">
ارسال پیامک
</BaseButton>
</template>-->
</template>
<div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobileScreen" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<!-- <template #extraHeader>
<BaseButton v-if="!mobile" type="primary" size="small" icon="vuesax-linear:sms" @click="emits('add')">
ارسال پیامک
</BaseButton>
</template>-->
<template #action="{ column, record }">
<!-- <div class="gap-2 flex justify-center">
<template v-if="groupSmsEnabled" #extraHeader>
<BaseButton v-if="!mobile" type="primary" size="small" icon="vuesax-linear:sms" @click="emits('add')">
ارسال پیامک
</BaseButton>
</template>
<template #action="{ column, record }">
<!-- <div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
@@ -23,10 +23,10 @@
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
</div>-->
</template>
</BaseTable>
</template>
</BaseTable>
<SmsMobileCardList v-else ref="mobileListRef" class="min-h-0 flex-1" :mobile="mobile" />
<SmsMobileCardList v-else ref="mobileListRef" class="min-h-0 flex-1" :mobile="mobile" />
</div>
</BasicShowContext>
</div>
@@ -39,6 +39,7 @@ import { cols } from '../hooks/smsColumns'
import { getSMSTable } from '@/api/sms'
import SmsMobileCardList from './SmsMobileCardList.vue'
import { isMobile as isMobileScreen } from '@core/utils/is'
import { groupSmsEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
interface Prop {
mobile?: number | string
@@ -49,6 +50,11 @@ const emits = defineEmits(['delete', 'edit', 'add', 'info'])
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
const groupSmsEnabled = groupSmsEnabledSetting
onMounted(() => {
void initWalletSettingsFromApi()
})
const tblProps = ref<any>({
columns: cols,
+66 -18
View File
@@ -15,29 +15,39 @@
:showButton="true"
:showCancel="true"
:showOk="true"
ok-text="ارسال"
ok-text="شروع ارسال"
cancel-text="انصراف"
@ok="ok"
@close="closeSmsSheet"
@cancel="closeSmsSheet"
>
<div v-if="isMobile" class="mb-3 text-base font-700">ارسال پیامک</div>
<SmsEditorFormBody ref="smsEditorRef" :form="form" :group-items="groupItems" :loading="loading" />
<a-alert
class="mb-3"
type="warning"
show-icon
message="ارسال ۱۰تایی انجام می‌شود. تا پایان کار پنل را نبندید و صفحه را رفرش نکنید."
/>
<div class="sms-editor-scroll">
<SmsEditorFormBody ref="smsEditorRef" :form="form" :group-items="groupItems" :loading="loading" />
</div>
</BasicActionSheet>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import tbl from './components/table.vue'
import type { FormInstance } from 'ant-design-vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import SmsEditorFormBody from './components/SmsEditorFormBody.vue'
import { message } from 'ant-design-vue/es'
import type { FunctionItem } from '@/model/Item'
import { createSMS, getGroupsList } from '@/api/sms'
import { getGroupsList } from '@/api/sms'
import type { Sms } from '@/model/sms'
import { isMobile } from '@core/utils/is'
import { groupSmsEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
import { useGroupSmsSendStore } from '@/store/groupSmsSendStore'
const loading = ref<boolean>(false)
const groupItems = ref<FunctionItem[]>()
@@ -46,6 +56,7 @@ const smsEditorRef = ref<{
validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>>
resetAll?: () => void
}>()
const sendStore = useGroupSmsSendStore()
const form = ref<Sms>({
message: '',
@@ -55,7 +66,17 @@ const form = ref<Sms>({
const addSMSVisible = ref<boolean>(false)
onMounted(() => getGroups())
onMounted(() => {
void initWalletSettingsFromApi()
getGroups()
})
watch(
() => sendStore.finished,
(finished) => {
if (finished) refreshTable()
}
)
async function getGroups() {
try {
@@ -73,10 +94,20 @@ function refreshTable() {
}
function closeSmsSheet() {
if (loading.value) return
addSMSVisible.value = false
}
async function add() {
if (!groupSmsEnabledSetting.value) {
message.warning('ارسال پیامک گروهی غیرفعال است')
return
}
if (sendStore.active) {
message.info('یک ارسال گروهی در حال انجام است')
sendStore.expand()
return
}
addSMSVisible.value = true
resetData()
}
@@ -90,22 +121,39 @@ function resetData() {
smsEditorRef.value?.resetAll?.()
}
function uniqueNumbers(list: string[]): string[] {
return Array.from(new Set(list.map((n) => String(n).trim()).filter(Boolean)))
}
function ok() {
if (sendStore.active) {
message.warning('ارسال قبلی هنوز تمام نشده است')
return
}
smsEditorRef.value?.validateFields?.().then(async () => {
try {
loading.value = true
if (!form.value.numbers) form.value.numbers = []
form.value.numbers.push(...(form.value.single ?? []))
await createSMS(form.value)
resetData()
addSMSVisible.value = false
refreshTable()
message.success('با موفقیت ارسال شد')
} catch (e) {
console.error(e)
} finally {
loading.value = false
const numbers = uniqueNumbers([...(form.value.numbers ?? []), ...(form.value.single ?? [])])
if (!numbers.length) {
message.warning('حداقل یک شماره انتخاب کنید')
return
}
if (!form.value.message?.trim()) {
message.warning('متن پیامک خالی است')
return
}
const text = form.value.message
addSMSVisible.value = false
resetData()
await sendStore.start(numbers, text)
})
}
</script>
<style scoped>
.sms-editor-scroll {
max-height: min(62vh, 560px);
overflow-y: auto;
padding-inline-end: 4px;
}
</style>
+20 -1
View File
@@ -1,5 +1,9 @@
import type { RouteRecordRaw } from 'vue-router'
import { conditionalOrderEnabledSetting, priceTriggerEnabledSetting } from '@/utils/config'
import {
conditionalOrderEnabledSetting,
priceProviderSetting,
priceTriggerEnabledSetting
} from '@/utils/config'
const HesabKetab: RouteRecordRaw = {
name: 'hesabKetab',
@@ -111,6 +115,21 @@ const HesabKetab: RouteRecordRaw = {
}
}
},
{
name: 'colleagueOrdersMain',
path: 'colleague-orders',
component: () => import('@/pages/menuPages/colleagueOrders/index.vue'),
meta: {
icon: 'vuesax-linear:people',
title: 'تراکنش همکار',
order: 6.8
},
beforeEnter() {
if (priceProviderSetting.value !== 'montzar') {
return { name: 'accountsMain' }
}
}
},
{
name: 'accountingMain',
path: 'accounting',
+187
View File
@@ -0,0 +1,187 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { chunk } from 'lodash'
import { createSMS } from '@/api/sms'
export interface GroupSmsSendError {
batch: number
numbers: string[]
error: string
}
export type GroupSmsSendStatus = 'idle' | 'running' | 'done' | 'canceled'
export type GroupSmsSendOutcome = 'running' | 'success' | 'error' | 'partial' | 'canceled'
/** هر درخواست حداکثر ۱۰ شماره — پنل پیامک زیر فشار نرود */
const BATCH_SIZE = 10
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
/**
* ارسال گروهی پیامک — مثل ایمپورت مشتری حسابداری:
* در پس‌زمینه ادامه می‌یابد، مینیمایز می‌شود، ۱۰تایی پیش می‌رود.
*/
export const useGroupSmsSendStore = defineStore('groupSmsSend', () => {
const status = ref<GroupSmsSendStatus>('idle')
const messageText = ref('')
const queue = ref<string[]>([])
const total = ref(0)
const processed = ref(0)
const success = ref(0)
const failed = ref(0)
const errors = ref<GroupSmsSendError[]>([])
const currentBatch = ref(0)
const totalBatches = ref(0)
const visible = ref(false)
const minimized = ref(false)
let cancelRequested = false
const active = computed(() => status.value === 'running')
const finished = computed(() => status.value === 'done' || status.value === 'canceled')
const percent = computed(() =>
total.value > 0 ? Math.min(100, Math.round((processed.value / total.value) * 100)) : 0,
)
const currentLabel = computed(() => {
if (!active.value) return ''
if (totalBatches.value <= 0) return '...'
return `دسته ${Math.min(currentBatch.value + 1, totalBatches.value)} از ${totalBatches.value}`
})
const outcome = computed<GroupSmsSendOutcome>(() => {
if (status.value === 'running' || status.value === 'idle') return 'running'
if (status.value === 'canceled') return 'canceled'
if (failed.value === 0) return 'success'
if (success.value === 0) return 'error'
return 'partial'
})
function reset() {
status.value = 'idle'
messageText.value = ''
queue.value = []
total.value = 0
processed.value = 0
success.value = 0
failed.value = 0
errors.value = []
currentBatch.value = 0
totalBatches.value = 0
visible.value = false
minimized.value = false
cancelRequested = false
}
function expand() {
minimized.value = false
}
function minimize() {
minimized.value = true
}
function cancel() {
if (status.value === 'running') cancelRequested = true
}
function dismiss() {
if (status.value === 'running') return
reset()
}
async function start(numbers: string[], message: string) {
if (status.value === 'running') return
const unique = Array.from(new Set(numbers.map((n) => String(n).trim()).filter(Boolean)))
if (!unique.length || !message.trim()) return
messageText.value = message
queue.value = unique
total.value = unique.length
processed.value = 0
success.value = 0
failed.value = 0
errors.value = []
currentBatch.value = 0
totalBatches.value = Math.ceil(unique.length / BATCH_SIZE)
cancelRequested = false
status.value = 'running'
visible.value = true
minimized.value = false
await runLoop()
}
async function sendBatchWithRetry(batchNumbers: string[], attempt = 0): Promise<void> {
try {
await createSMS({
message: messageText.value,
numbers: batchNumbers
})
} catch (e) {
if (attempt < 2 && !cancelRequested) {
await sleep(1500 * (attempt + 1))
return sendBatchWithRetry(batchNumbers, attempt + 1)
}
throw e
}
}
async function runLoop() {
const batches = chunk(queue.value, BATCH_SIZE)
for (let i = 0; i < batches.length; i++) {
if (cancelRequested) {
status.value = 'canceled'
return
}
currentBatch.value = i
const batchNumbers = batches[i]
try {
await sendBatchWithRetry(batchNumbers)
success.value += batchNumbers.length
} catch {
failed.value += batchNumbers.length
errors.value.push({
batch: i + 1,
numbers: batchNumbers,
error: 'خطا در ارتباط با پنل پیامک'
})
}
processed.value += batchNumbers.length
if (i < batches.length - 1) await sleep(250)
}
status.value = 'done'
}
return {
status,
messageText,
queue,
total,
processed,
success,
failed,
errors,
currentBatch,
totalBatches,
visible,
minimized,
active,
finished,
percent,
currentLabel,
outcome,
start,
cancel,
dismiss,
expand,
minimize,
reset
}
})
+13
View File
@@ -55,6 +55,9 @@ export const customerLevelProfitEnabledSetting = ref(false)
/** reactive — منو و UI هشدار قیمت */
export const priceTriggerEnabledSetting = ref(false)
/** reactive — دکمه ارسال پیامک گروهی در صفحه پیامک‌ها */
export const groupSmsEnabledSetting = ref(false)
/** reactive — منو و زنگوله اعلان داخل اپ */
export const appNotificationEnabledSetting = ref(true)
@@ -149,6 +152,10 @@ export function setCustomerDirectSmsEnabledFromSettings(value: boolean): void {
customerDirectSmsEnabledSetting.value = value
}
export function setGroupSmsEnabledFromSettings(value: boolean): void {
groupSmsEnabledSetting.value = value
}
export function setConditionalOrderEnabledFromSettings(value: boolean): void {
conditionalOrderEnabledSetting.value = value
}
@@ -250,6 +257,11 @@ export function applyCustomerDirectSmsFromRows(rows: Array<{ key?: string; value
if (row) setCustomerDirectSmsEnabledFromSettings(isSwitchOn(row.value))
}
export function applyGroupSmsFromRows(rows: Array<{ key?: string; value?: string | null }>) {
const row = rows.find((item) => item.key === 'group_sms')
if (row) setGroupSmsEnabledFromSettings(isSwitchOn(row.value))
}
export function applyConditionalOrderEnabledFromRows(
rows: Array<{ key?: string; value?: string | null }>
) {
@@ -317,6 +329,7 @@ async function bootstrapSettingsFromApi(): Promise<void> {
applyBannerEnabledFromRows(list)
applyTrackingSystemsEnabledFromRows(list)
applyCustomerDirectSmsFromRows(list)
applyGroupSmsFromRows(list)
applyConditionalOrderEnabledFromRows(list)
applyProductFundsEnabledFromRows(list)
applyCustomerLevelProfitEnabledFromRows(list)