diff --git a/components.d.ts b/components.d.ts index 9f43faa..c0b91db 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/src/App.vue b/src/App.vue index c5c70ec..d50c623 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ + @@ -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' diff --git a/src/api/order.ts b/src/api/order.ts index f7c5ad5..b09aa77 100644 --- a/src/api/order.ts +++ b/src/api/order.ts @@ -6,6 +6,9 @@ import type { AddId, Customer } from '@/model/customer' export const getOrderTable = async (params: TableInput) => await useAxios.post>('/admin/order/table', params) +export const getColleagueOrderTable = async (params: TableInput) => + await useAxios.post>('/admin/order/colleague/table', params) + export const createOrder = async (obj: Order) => await useAxios.post(`/admin/single/order`, obj, { meta: { silent: true } }) diff --git a/src/components/GroupSmsSendProgress.vue b/src/components/GroupSmsSendProgress.vue new file mode 100644 index 0000000..25de97b --- /dev/null +++ b/src/components/GroupSmsSendProgress.vue @@ -0,0 +1,714 @@ + + + + + diff --git a/src/pages/menuPages/colleagueOrders/components/table.vue b/src/pages/menuPages/colleagueOrders/components/table.vue new file mode 100644 index 0000000..d1993fa --- /dev/null +++ b/src/pages/menuPages/colleagueOrders/components/table.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns.tsx b/src/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns.tsx new file mode 100644 index 0000000..63ddca5 --- /dev/null +++ b/src/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns.tsx @@ -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 ( +
+ + فروش + +
+ ) + } + return ( +
+ + خرید + +
+ ) + } + }, + { + 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) + } + ] +} diff --git a/src/pages/menuPages/colleagueOrders/index.vue b/src/pages/menuPages/colleagueOrders/index.vue new file mode 100644 index 0000000..278b4ae --- /dev/null +++ b/src/pages/menuPages/colleagueOrders/index.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/pages/menuPages/configuration/index.vue b/src/pages/menuPages/configuration/index.vue index bd77457..39114db 100644 --- a/src/pages/menuPages/configuration/index.vue +++ b/src/pages/menuPages/configuration/index.vue @@ -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) { diff --git a/src/pages/menuPages/configuration/settingGroups.ts b/src/pages/menuPages/configuration/settingGroups.ts index 6c0a59f..1de8669 100644 --- a/src/pages/menuPages/configuration/settingGroups.ts +++ b/src/pages/menuPages/configuration/settingGroups.ts @@ -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: 'چت پشتیبانی', diff --git a/src/pages/menuPages/configuration/settingTypes.ts b/src/pages/menuPages/configuration/settingTypes.ts index 3a5ab41..10be518 100644 --- a/src/pages/menuPages/configuration/settingTypes.ts +++ b/src/pages/menuPages/configuration/settingTypes.ts @@ -824,7 +824,7 @@ const KEY_HELP_FALLBACK: Record = { sms_notif_support_admin: 'روشن: وقتی مشتری اولین پیام خوانده‌نشده را در چت پشتیبانی می‌فرستد و ادمین همان گفتگو را باز و visible ندارد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان پیامک چت نمی‌رود.', group_sms: - 'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است. خاموش: ارسال پیامک گروهی غیرفعال است.', + 'روشن: دکمه ارسال پیامک گروهی در پنل ادمین فعال است (ارسال ۱۰تایی). خاموش: ارسال پیامک گروهی غیرفعال است. فقط از تنظیمات پیشرفته.', profit_gold_api_enabled: 'روشن: محاسبه سود طلا از API خارجی فعال است. خاموش: محاسبه سود از API خارجی انجام نمی‌شود (حالت پیش‌فرض اکثر نصب‌ها).', main_wallet_products: diff --git a/src/pages/menuPages/hesabKetab/index.vue b/src/pages/menuPages/hesabKetab/index.vue index cc847de..e45b452 100644 --- a/src/pages/menuPages/hesabKetab/index.vue +++ b/src/pages/menuPages/hesabKetab/index.vue @@ -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)) }) diff --git a/src/pages/menuPages/sms/components/SmsEditorFormBody.vue b/src/pages/menuPages/sms/components/SmsEditorFormBody.vue index 1ac0aaf..903da4f 100644 --- a/src/pages/menuPages/sms/components/SmsEditorFormBody.vue +++ b/src/pages/menuPages/sms/components/SmsEditorFormBody.vue @@ -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" />
diff --git a/src/pages/menuPages/sms/components/table.vue b/src/pages/menuPages/sms/components/table.vue index de47bdc..39d23b7 100644 --- a/src/pages/menuPages/sms/components/table.vue +++ b/src/pages/menuPages/sms/components/table.vue @@ -1,21 +1,21 @@
- - - + + - +
@@ -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 Promise }>(null) +const groupSmsEnabled = groupSmsEnabledSetting + +onMounted(() => { + void initWalletSettingsFromApi() +}) const tblProps = ref({ columns: cols, diff --git a/src/pages/menuPages/sms/index.vue b/src/pages/menuPages/sms/index.vue index e79c861..43a3ce5 100644 --- a/src/pages/menuPages/sms/index.vue +++ b/src/pages/menuPages/sms/index.vue @@ -15,29 +15,39 @@ :showButton="true" :showCancel="true" :showOk="true" - ok-text="ارسال" + ok-text="شروع ارسال" cancel-text="انصراف" @ok="ok" @close="closeSmsSheet" @cancel="closeSmsSheet" >
ارسال پیامک
- + +
+ +
+ + diff --git a/src/router/routes/hesabKetab.ts b/src/router/routes/hesabKetab.ts index 4f46f38..f4cf95a 100644 --- a/src/router/routes/hesabKetab.ts +++ b/src/router/routes/hesabKetab.ts @@ -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', diff --git a/src/store/groupSmsSendStore.ts b/src/store/groupSmsSendStore.ts new file mode 100644 index 0000000..ffc9fcc --- /dev/null +++ b/src/store/groupSmsSendStore.ts @@ -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('idle') + const messageText = ref('') + const queue = ref([]) + const total = ref(0) + const processed = ref(0) + const success = ref(0) + const failed = ref(0) + const errors = ref([]) + 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(() => { + 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 { + 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 + } +}) diff --git a/src/utils/config.ts b/src/utils/config.ts index d74a450..70b7925 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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 { applyBannerEnabledFromRows(list) applyTrackingSystemsEnabledFromRows(list) applyCustomerDirectSmsFromRows(list) + applyGroupSmsFromRows(list) applyConditionalOrderEnabledFromRows(list) applyProductFundsEnabledFromRows(list) applyCustomerLevelProfitEnabledFromRows(list)