Files
boursecu_admin/src/pages/menuPages/customers/online/index.vue
T

681 lines
22 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.
<template>
<div class="online-page h-full min-h-0 flex flex-col overflow-hidden">
<div class="online-page__hero shrink-0 mb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="online-page__title m-0 inline-flex items-center gap-1.5">
کاربران آنلاین
<AdminFieldHelp :text="customersOnlinePageIntro" size="sm" />
</h2>
</div>
<div class="flex items-center gap-2">
<div class="online-page__count">
<span class="online-page__count-dot" />
<span class="font-700">{{ count }}</span>
<span class="text-sm opacity-80">نفر آنلاین</span>
</div>
<a-button type="primary" size="small" :loading="loading" @click="load">
به‌روزرسانی
</a-button>
</div>
</div>
</div>
<a-spin :spinning="loading" class="flex-1 min-h-0">
<div v-if="!loading && !rows.length" class="online-page__empty">
<span class="online-page__empty-dot" />
<p class="m-0 mt-3 font-600">الان کسی آنلاین نیست</p>
<p class="m-0 mt-1 text-sm opacity-70">با باز شدن اپ توسط مشتری، اینجا ظاهر می‌شود</p>
</div>
<div v-else class="online-page__grid overflow-y-auto min-h-0 pb-4">
<article
v-for="row in rows"
:key="row.id"
class="online-card"
role="button"
tabindex="0"
@click="openSheet(row)"
@keydown.enter.prevent="openSheet(row)"
>
<div class="online-card__top">
<span class="online-card__pulse" />
<span class="online-card__badge">آنلاین</span>
</div>
<h3 class="online-card__name m-0">{{ row.name || '—' }}</h3>
<p class="online-card__mobile m-0" dir="ltr">{{ row.mobile || '—' }}</p>
<div class="online-card__meta">
<span>کد {{ row.id }}</span>
<span>{{ formatSeen(row.last_seen_at) }}</span>
</div>
<div class="online-card__actions" @click.stop>
<a-tooltip placement="bottom">
<template #title><span>بایگانی</span></template>
<a-popconfirm
title="آیا مشتری را بایگانی می کنید؟"
ok-text="تایید"
cancel-text="رد"
placement="topLeft"
@confirm="archiveFunction(row.id)"
>
<Icon class="cursor-pointer" icon="vuesax-linear:archive-tick" color="#e3bf00" />
</a-popconfirm>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="changeCustomerActivationFunction(row.id)">
<template #title>
<span>{{ row.active ? 'غیرفعال کردن حساب کاربری' : 'فعال کردن حساب کاربری' }}</span>
</template>
<Icon
:icon="row.active ? 'vuesax-linear:user-tick' : 'vuesax-linear:user-remove'"
:color="row.active ? '#54AD06' : '#ad0627'"
/>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="openWalletCharge(row)">
<template #title><span>شارژ کیف پول</span></template>
<Icon icon="vuesax-linear:wallet-add" color="#9333ea" />
</a-tooltip>
<a-tooltip
v-if="tahesabEnabled && !isValidAccountingCode(row.moshtari_code)"
placement="bottom"
class="cursor-pointer"
@click="openTahesabRegister(row)"
>
<template #title><span>در ته‌حساب ثبت نشده — کلیک برای ثبت</span></template>
<Icon icon="vuesax-linear:profile-add" color="#CA8A04" />
</a-tooltip>
<a-tooltip
v-if="zarinEnabled && !isValidAccountingCode(row.moshtari_code)"
placement="bottom"
class="cursor-pointer"
@click="openZarinRegister(row)"
>
<template #title><span>در زرین ثبت نشده — کلیک برای ثبت</span></template>
<Icon icon="vuesax-linear:profile-add" color="#0284C7" />
</a-tooltip>
<a-tooltip
v-if="pargarEnabled && !isValidAccountingCode(row.pargar_account_id)"
placement="bottom"
class="cursor-pointer"
@click="openPargarRegister(row)"
>
<template #title><span>در پرگار ثبت نشده — کلیک برای ثبت</span></template>
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-tooltip
v-if="accountingEnabled"
placement="bottom"
class="cursor-pointer"
@click="openAccountingCodeEdit(row)"
>
<template #title><span>ویرایش کد حسابداری</span></template>
<Icon icon="vuesax-linear:card-edit" color="#0d9488" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="goInfo(row)">
<template #title><span>مشخصات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="openEdit(row)">
<template #title><span>ویرایش</span></template>
<Icon icon="vuesax-linear:edit" color="#4572E9" />
</a-tooltip>
<a-tooltip
placement="bottom"
class="cursor-pointer"
@click="changeCustomerOrderActivationFunction(row.id)"
>
<template #title>
<span>{{ row.trade_active ? 'غیر مجاز کردن سفارش دهی' : 'مجاز کردن سفارش دهی' }}</span>
</template>
<Icon
:icon="row.trade_active === 1 ? 'vuesax-linear:box-tick' : 'vuesax-linear:box-remove'"
:color="row.trade_active === 1 ? '#54AD06' : '#ad0627'"
/>
</a-tooltip>
<a-tooltip
v-if="customerDirectSmsEnabled"
placement="bottom"
class="cursor-pointer"
@click="openDirectSms(row)"
>
<template #title><span>ارسال پیامک</span></template>
<Icon icon="vuesax-linear:sms" color="#0ea5e9" />
</a-tooltip>
<a-tooltip
v-if="!row.is_legal"
placement="bottom"
class="cursor-pointer"
@click="openConfirmUser(row)"
>
<template #title><span>تایید کاربر</span></template>
<Icon icon="vuesax-linear:user-search" color="#0d9488" />
</a-tooltip>
</div>
</article>
</div>
</a-spin>
<CustomerMobileDetailSheet
v-model:visible="sheetVisible"
:item="selectedItem"
@info="goInfo"
@edit="openEdit"
@archive="handleArchive"
@toggleActive="(item) => changeCustomerActivationFunction(item.id)"
@chargeWallet="openWalletCharge"
@toggleTrade="(item) => changeCustomerOrderActivationFunction(item.id)"
@confirmUser="openConfirmUser"
@sendSms="openDirectSms"
@editAccounting="openAccountingCodeEdit"
/>
<WalletChargeModal
v-model:visible="showWalletCharge"
:customer-id="chargeCustomerId"
@success="load"
/>
<CustomerDirectSmsModal v-model:visible="directSmsVisible" :customer="directSmsCustomer" />
<CustomerAccountingCodeModal
v-model:visible="accountingCodeVisible"
:customer="accountingCodeCustomer"
:provider="accountingProviderSetting"
@success="load"
/>
<BasicModal
v-model:visible="confirmVisible"
:width="isMobile ? '92vw' : '50vw'"
title="تایید مشتری"
@ok="confirmUserFunction"
ok-text="تایید"
:loading="actionLoading"
@close="confirmVisible = false"
>
<div class="mt-[-10px] mb-10 font-bold">آیا از اطلاعات وارد شده اطمینان دارید؟</div>
<div class="grid grid-cols-4 gap-2">
<BasicInfoItem title="نام و نام خانوادگی" :value="confirmInfo?.name" />
<BasicInfoItem title="شماره همراه" :value="confirmInfo?.mobile" />
<BasicInfoItem
title="تاریخ تولد"
:value="confirmInfo?.birth_date ? toJalaliNoTime(confirmInfo?.birth_date) : '_'"
/>
<BasicInfoItem title="شماره تلفن" :value="confirmInfo?.phone_number ?? '_'" />
<BasicInfoItem title="نام استان" :value="confirmInfo?.province" />
<BasicInfoItem title="نام شهر" :value="confirmInfo?.city" />
<BasicInfoItem title="شماره ملی" :value="confirmInfo?.n_code" />
</div>
</BasicModal>
<BasicActionSheet
v-model:visible="editVisible"
title="ویرایش کاربر"
modal-width="55rem"
:loading="actionLoading"
:showButton="true"
:showCancel="true"
:showOk="true"
ok-text="ثبت"
cancel-text="انصراف"
@ok="submitEdit"
@close="editVisible = false"
>
<a-form :model="editForm" ref="editFormRef" autocomplete="off">
<div class="grid gap-4 md:grid-cols-3">
<a-form-item label="نام" name="fname" :rules="inputRules()">
<BasicInput v-model:value="editForm.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" name="lname" :rules="inputRules()">
<BasicInput v-model:value="editForm.lname" />
</a-form-item>
<a-form-item label="شماره همراه" name="mobile" :rules="inputLenRules(11)">
<BasicInput v-model:value="editForm.mobile" type="number" />
</a-form-item>
<a-form-item label="پسورد" name="password">
<BasicInput is-password v-model:value="editForm.password" />
</a-form-item>
<a-form-item label="تاریخ تولد" name="birth_date">
<BaseDatePicker v-model:value="editForm.birth_date" />
</a-form-item>
<a-form-item label="شماره تلفن" name="phone_number">
<BasicInput v-model:value="editForm.phone_number" type="number" />
</a-form-item>
<a-form-item label="نام استان" name="province">
<BasicInput v-model:value="editForm.province" />
</a-form-item>
<a-form-item label="نام شهر" name="city">
<BasicInput v-model:value="editForm.city" />
</a-form-item>
<a-form-item label="شماره ملی" name="n_code">
<BasicInput v-model:value="editForm.n_code" />
</a-form-item>
</div>
</a-form>
</BasicActionSheet>
<TahesabRetryModal
v-if="tahesabEnabled"
v-model="tahesabRegisterVisible"
mode="register-customer"
:customer-id="tahesabCustomer?.id"
:customer-name="tahesabCustomer?.name"
@success="load"
/>
<ZarinRetryModal
v-if="zarinEnabled"
v-model="zarinRegisterVisible"
mode="register-customer"
:customer-id="zarinCustomer?.id"
:customer-name="zarinCustomer?.name"
@success="load"
/>
<PargarRetryModal
v-if="pargarEnabled"
v-model="pargarRegisterVisible"
mode="register-customer"
:customer-id="pargarCustomer?.id"
:customer-name="pargarCustomer?.name"
@success="load"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { cloneDeep } from 'lodash'
import type { FormInstance } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import {
changeCustomerActivation,
changeCustomerArchive,
changeCustomerOrderActivation,
confirmUser,
getOnlineCustomers,
updateCustomer
} from '@/api/customer'
import type { Customer } from '@/model/customer'
import { toJalali, toJalaliNoTime } from '@core/utils/useDataTime'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import { isMobile } from '@core/utils/is'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { customersOnlinePageIntro } from '@/utils/adminHelpCopy'
import { MODAL_ABOVE_ACTION_SHEET_Z_INDEX } from '@/constants/overlayZIndex'
import { inputLenRules, inputRules } from '@/utils/inputRules'
import CustomerMobileDetailSheet from '@/pages/menuPages/customers/base/components/CustomerMobileDetailSheet.vue'
import WalletChargeModal from '@/pages/menuPages/customers/base/components/WalletChargeModal.vue'
import CustomerDirectSmsModal from '@/pages/menuPages/customers/base/components/CustomerDirectSmsModal.vue'
import CustomerAccountingCodeModal from '@/pages/menuPages/customers/base/components/CustomerAccountingCodeModal.vue'
import TahesabRetryModal from '@/components/TahesabRetryModal.vue'
import ZarinRetryModal from '@/components/ZarinRetryModal.vue'
import PargarRetryModal from '@/components/PargarRetryModal.vue'
import {
accountingProviderSetting,
customerDirectSmsEnabledSetting,
initAccountingSettingsFromApi,
initWalletSettingsFromApi,
pargarEnabledSetting,
tahesabEnabledSetting,
zarinEnabledSetting
} from '@/utils/config'
import { isValidAccountingCode } from '@/utils/accountingCode'
const router = useRouter()
const loading = ref(false)
const actionLoading = ref(false)
const rows = ref<Customer[]>([])
const count = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
const sheetVisible = ref(false)
const selectedItem = ref<Customer | null>(null)
const showWalletCharge = ref(false)
const chargeCustomerId = ref<number>()
const directSmsVisible = ref(false)
const directSmsCustomer = ref<Customer | null>(null)
const accountingCodeVisible = ref(false)
const accountingCodeCustomer = ref<Customer | null>(null)
const confirmVisible = ref(false)
const confirmInfo = ref<Customer>()
const tahesabRegisterVisible = ref(false)
const tahesabCustomer = ref<Customer>()
const tahesabEnabled = tahesabEnabledSetting
const zarinRegisterVisible = ref(false)
const zarinCustomer = ref<Customer>()
const zarinEnabled = zarinEnabledSetting
const pargarRegisterVisible = ref(false)
const pargarCustomer = ref<Customer>()
const pargarEnabled = pargarEnabledSetting
const customerDirectSmsEnabled = customerDirectSmsEnabledSetting
const accountingEnabled = computed(() => {
const provider = accountingProviderSetting.value
return provider === 'pargar' || provider === 'tahesab' || provider === 'zarin'
})
const editVisible = ref(false)
const editFormRef = ref<FormInstance>()
const editId = ref<number>()
const editForm = ref<Partial<Customer>>({})
function formatSeen(v?: string | null) {
return v ? toJalali(v) : '—'
}
function openSheet(row: Customer) {
selectedItem.value = row
sheetVisible.value = true
}
function goInfo(row: Customer) {
sheetVisible.value = false
void router.push({ name: 'customerMain_limitedOrder', query: { infoId: String(row.id) } })
}
function openEdit(row: Customer) {
sheetVisible.value = false
editId.value = row.id
editForm.value = cloneDeep(row)
editVisible.value = true
}
async function submitEdit() {
try {
await editFormRef.value?.validate()
if (!editId.value) return
actionLoading.value = true
const payload: Partial<Customer> = {
fname: editForm.value.fname,
lname: editForm.value.lname,
mobile: editForm.value.mobile,
birth_date: editForm.value.birth_date,
phone_number: editForm.value.phone_number,
province: editForm.value.province,
city: editForm.value.city,
n_code: editForm.value.n_code,
}
const password = String(editForm.value.password || '').trim()
if (password !== '') {
payload.password = password
}
await updateCustomer(editId.value, payload as Customer)
message.success('ویرایش با موفقیت انجام شد')
editVisible.value = false
await load()
} catch (e) {
console.error(e)
} finally {
actionLoading.value = false
}
}
function openWalletCharge(row: Customer) {
sheetVisible.value = false
chargeCustomerId.value = row.id
showWalletCharge.value = true
}
function openDirectSms(row: Customer) {
sheetVisible.value = false
directSmsCustomer.value = row
directSmsVisible.value = true
}
function openAccountingCodeEdit(row: Customer) {
sheetVisible.value = false
accountingCodeCustomer.value = row
accountingCodeVisible.value = true
}
function openConfirmUser(row: Customer) {
sheetVisible.value = false
confirmInfo.value = row
confirmVisible.value = true
}
function openTahesabRegister(row: Customer) {
tahesabCustomer.value = row
tahesabRegisterVisible.value = true
}
function openZarinRegister(row: Customer) {
zarinCustomer.value = row
zarinRegisterVisible.value = true
}
function openPargarRegister(row: Customer) {
pargarCustomer.value = row
pargarRegisterVisible.value = true
}
function handleArchive(row: Customer) {
Modal.confirm({
title: 'آیا مشتری را بایگانی می کنید؟',
okText: 'تایید',
cancelText: 'رد',
centered: true,
zIndex: MODAL_ABOVE_ACTION_SHEET_Z_INDEX,
onOk: () => archiveFunction(row.id)
})
}
async function archiveFunction(id?: number) {
if (!id) return
try {
actionLoading.value = true
await changeCustomerArchive(id)
message.success('مشتری با موفقیت بایگانی شد.')
sheetVisible.value = false
await load()
} catch (e) {
console.error(e)
} finally {
actionLoading.value = false
}
}
async function changeCustomerActivationFunction(id?: number) {
if (!id) return
try {
actionLoading.value = true
await changeCustomerActivation(id)
message.success('تغییر وضعیت با موفقیت انجام شد')
await load()
} catch (e) {
console.error(e)
} finally {
actionLoading.value = false
}
}
async function changeCustomerOrderActivationFunction(id?: number) {
if (!id) return
try {
actionLoading.value = true
await changeCustomerOrderActivation(id)
message.success('تغییر وضعیت سفارش دهی با موفقیت انجام شد')
await load()
} catch (e) {
console.error(e)
} finally {
actionLoading.value = false
}
}
async function confirmUserFunction() {
try {
actionLoading.value = true
await confirmUser(confirmInfo.value?.id)
confirmVisible.value = false
message.success('تایید کاربر انجام شد')
await load()
} catch (e) {
console.log(e)
} finally {
actionLoading.value = false
}
}
async function load() {
loading.value = true
try {
const res = await getOnlineCustomers()
rows.value = res?.rows ?? []
count.value = res?.count ?? rows.value.length
if (selectedItem.value?.id) {
selectedItem.value = rows.value.find((r) => r.id === selectedItem.value?.id) ?? null
if (!selectedItem.value) sheetVisible.value = false
}
} catch (e) {
console.log(e)
message.error('دریافت لیست آنلاین ممکن نیست')
} finally {
loading.value = false
}
}
onMounted(() => {
void initAccountingSettingsFromApi()
void initWalletSettingsFromApi()
void load()
pollTimer = setInterval(() => {
void load()
}, 30000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.online-page__hero {
padding: 1rem 1.15rem;
border-radius: 1rem;
border: 1px solid var(--admin-border, rgba(212, 175, 55, 0.35));
background: linear-gradient(
135deg,
rgba(212, 175, 55, 0.12) 0%,
rgba(34, 197, 94, 0.08) 55%,
transparent 100%
);
}
.online-page__title {
font-size: 1.15rem;
font-weight: 800;
color: var(--admin-text, #f5f5f5);
}
.online-page__count {
@apply flex items-center gap-2 rounded-full px-3 py-1.5 text-sm;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.35);
color: #4ade80;
}
.online-page__count-dot {
@apply h-2.5 w-2.5 rounded-full bg-green-500 shrink-0;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
animation: online-pulse 1.6s ease-out infinite;
}
.online-page__empty {
@apply flex flex-col items-center justify-center py-16 text-center;
color: var(--admin-text-secondary, #a3a3a3);
}
.online-page__empty-dot {
@apply h-3 w-3 rounded-full bg-gray-500 opacity-60;
}
.online-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
gap: 0.85rem;
max-height: calc(100vh - 14rem);
}
.online-card {
position: relative;
padding: 1rem 1rem 0.9rem;
border-radius: 0.9rem;
border: 1px solid rgba(34, 197, 94, 0.28);
background: linear-gradient(160deg, rgba(34, 197, 94, 0.1) 0%, rgba(0, 0, 0, 0.2) 70%);
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.online-card:hover {
border-color: rgba(34, 197, 94, 0.55);
transform: translateY(-1px);
}
.online-card::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 0;
width: 3px;
background: #22c55e;
}
.online-card__top {
@apply flex items-center gap-2 mb-2;
}
.online-card__pulse {
@apply h-2.5 w-2.5 rounded-full bg-green-500;
animation: online-pulse 1.6s ease-out infinite;
}
.online-card__badge {
@apply text-xs font-700;
color: #4ade80;
}
.online-card__name {
font-size: 1rem;
font-weight: 800;
color: var(--admin-text, #f5f5f5);
}
.online-card__mobile {
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--admin-gold-light, #d4af37);
font-weight: 600;
}
.online-card__meta {
@apply mt-3 flex items-center justify-between gap-2 text-xs;
color: var(--admin-text-secondary, #a3a3a3);
}
.online-card__actions {
@apply mt-3 flex flex-wrap items-center justify-center gap-2 pt-2;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
@keyframes online-pulse {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55);
}
70% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
</style>