Require review before Tahesab wallet sync and harden accounting admin UX.
Single-customer and row sync preview balances with documents before overwrite/keep; bulk sync keeps confirm. Also ship Zarin wallet sync actions, field help, and get_user cache improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div class="cs-wrap">
|
||||
|
||||
<PargarWalletBulkSync />
|
||||
|
||||
<ColleagueAccountingAccounts provider-only="pargar" />
|
||||
|
||||
<!-- ─── بنر آمار ─── -->
|
||||
<div class="cs-banner">
|
||||
<div class="cs-banner__title">همگامسازی مشتریان</div>
|
||||
<div class="cs-banner__title-row">
|
||||
<div class="cs-banner__title">همگامسازی مشتریان</div>
|
||||
<AdminFieldHelp :text="accountingPargarCustomerSyncIntro" size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- آمار اصلی: ردیف بالا -->
|
||||
<div class="cs-stats-main">
|
||||
@@ -327,8 +328,9 @@ import {
|
||||
type SyncPanelResult,
|
||||
} from '@/api/pargar'
|
||||
import { useCustomerImportStore } from '@/store/customerImportStore'
|
||||
import PargarWalletBulkSync from './PargarWalletBulkSync.vue'
|
||||
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
|
||||
import { accountingPargarCustomerSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
|
||||
const importStore = useCustomerImportStore()
|
||||
|
||||
@@ -580,6 +582,7 @@ onMounted(() => {
|
||||
@media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
|
||||
|
||||
&__title { font-size: 14px; font-weight: 700; }
|
||||
&__title-row { display: inline-flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ردیف اصلی: پنل ↔ پرگار */
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<Icon icon="vuesax-linear:refresh-circle" :size="20" color="var(--admin-primary, #10913A)" />
|
||||
</div>
|
||||
<div class="pw-bulk__copy min-w-0 flex-1">
|
||||
<p class="pw-bulk__title">همگامسازی ماندهٔ همهٔ مشتریان</p>
|
||||
<div class="pw-bulk__title-row">
|
||||
<p class="pw-bulk__title">همگامسازی ماندهٔ همهٔ مشتریان</p>
|
||||
<AdminFieldHelp :text="accountingPargarBalanceSyncIntro" size="sm" />
|
||||
</div>
|
||||
<p class="pw-bulk__hint">
|
||||
موجودی کیف همهٔ مشتریان دارای کد پرگار را با ماندهٔ پرگار یکی میکند — تاریخچهٔ اسناد روی کیف
|
||||
replay نمیشود
|
||||
@@ -64,6 +67,8 @@ import { computed, createVNode, ref } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import { accountingPargarBalanceSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
import {
|
||||
syncAllWalletsFromPargarChunk,
|
||||
type PargarBulkWalletSyncChunk,
|
||||
@@ -184,6 +189,10 @@ async function start() {
|
||||
color: var(--admin-text, inherit);
|
||||
}
|
||||
|
||||
.pw-bulk__title-row {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.pw-bulk__hint {
|
||||
@apply m-0 mt-0.5 text-xs leading-5;
|
||||
color: var(--admin-text-secondary, #6b7280);
|
||||
|
||||
@@ -1,64 +1,65 @@
|
||||
<template>
|
||||
<div class="cs-wrap">
|
||||
|
||||
<TahesabWalletBulkSync />
|
||||
|
||||
<ColleagueAccountingAccounts provider-only="tahesab" />
|
||||
|
||||
<!-- ─── بنر آمار ─── -->
|
||||
<!-- ─── آمار فشرده ─── -->
|
||||
<div class="cs-banner">
|
||||
<div class="cs-banner__title">همگامسازی مشتریان</div>
|
||||
|
||||
<!-- آمار اصلی: ردیف بالا -->
|
||||
<div class="cs-stats-main">
|
||||
<div class="cs-stat cs-stat--neutral">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status">{{ status.panel_total }}</template>
|
||||
<a-spin v-else size="small" />
|
||||
</div>
|
||||
<div class="cs-stat__label">مشتریان پنل</div>
|
||||
</div>
|
||||
<div class="cs-sep-arrows">
|
||||
<Icon icon="vuesax-linear:arrow-left" :size="16" />
|
||||
<Icon icon="vuesax-linear:arrow-right" :size="16" />
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--tahesab">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status?.tahesab_total != null">{{ status.tahesab_total }}</template>
|
||||
<a-spin v-else-if="loadingTahesab" size="small" />
|
||||
<span v-else class="cs-stat__hint">تب دوم</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">مشتریان تهحساب</div>
|
||||
</div>
|
||||
<div class="cs-banner__top">
|
||||
<span class="cs-banner__title-row">
|
||||
<span class="cs-banner__title">همگامسازی مشتریان</span>
|
||||
<AdminFieldHelp :text="accountingTahesabCustomerSyncIntro" size="sm" />
|
||||
</span>
|
||||
<span v-if="status" class="cs-banner__meta">
|
||||
{{ registeredPercent }}٪ لینکشده
|
||||
· {{ status.panel_registered }} / {{ status.panel_total }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- آمار تفاوت: ردیف پایین -->
|
||||
<div class="cs-stats-diff">
|
||||
<div class="cs-stats">
|
||||
<div class="cs-stat">
|
||||
<span class="cs-stat__num">
|
||||
<template v-if="status">{{ status.panel_total }}</template>
|
||||
<a-spin v-else size="small" />
|
||||
</span>
|
||||
<span class="cs-stat__label">پنل</span>
|
||||
</div>
|
||||
<div class="cs-stat">
|
||||
<span class="cs-stat__num">
|
||||
<template v-if="status?.tahesab_total != null">{{ status.tahesab_total }}</template>
|
||||
<a-spin v-else-if="loadingTahesab" size="small" />
|
||||
<span v-else class="cs-stat__hint">—</span>
|
||||
</span>
|
||||
<span class="cs-stat__label">کل تهحساب</span>
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--sky">
|
||||
<span class="cs-stat__num">
|
||||
<template v-if="status?.tahesab_with_phone_count != null">{{ status.tahesab_with_phone_count }}</template>
|
||||
<a-spin v-else-if="loadingTahesab" size="small" />
|
||||
<span v-else class="cs-stat__hint">—</span>
|
||||
</span>
|
||||
<span class="cs-stat__label">تهحساب با شماره</span>
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--amber">
|
||||
<div class="cs-stat__num">
|
||||
<span class="cs-stat__num">
|
||||
<template v-if="status">{{ status.panel_only }}</template>
|
||||
<a-spin v-else-if="loadingPanel" size="small" />
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">پنل — بدون تهحساب</div>
|
||||
</span>
|
||||
<span class="cs-stat__label">فقط پنل</span>
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--sky">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status?.tahesab_only_count != null">{{ status.tahesab_only_count }}</template>
|
||||
<div class="cs-stat">
|
||||
<span class="cs-stat__num">
|
||||
<template v-if="status?.tahesab_importable_count != null">{{ status.tahesab_importable_count }}</template>
|
||||
<a-spin v-else-if="loadingTahesab" size="small" />
|
||||
<span v-else class="cs-stat__hint">تب دوم</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">تهحساب — بدون پنل</div>
|
||||
<span v-else class="cs-stat__hint">—</span>
|
||||
</span>
|
||||
<span class="cs-stat__label">قابل وارد کردن</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- progress -->
|
||||
<div v-if="status" class="cs-progress">
|
||||
<div class="cs-progress__labels">
|
||||
<span>{{ registeredPercent }}٪ پنل در تهحساب</span>
|
||||
<span>{{ status.panel_registered }} / {{ status.panel_total }}</span>
|
||||
</div>
|
||||
<div class="cs-track"><div class="cs-track__fill" :style="{ width: registeredPercent + '%' }" /></div>
|
||||
<div v-if="status" class="cs-track">
|
||||
<div class="cs-track__fill" :style="{ width: registeredPercent + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,8 +70,7 @@
|
||||
:class="{ 'cs-tab--active': activeTab === 'panel' }"
|
||||
@click="onTabChange('panel')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:arrow-left" :size="14" />
|
||||
پنل ← تهحساب
|
||||
از پنل به تهحساب
|
||||
<span v-if="status?.panel_only" class="cs-tab__badge cs-tab__badge--amber">{{ status.panel_only }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -78,9 +78,8 @@
|
||||
:class="{ 'cs-tab--active': activeTab === 'tahesab' }"
|
||||
@click="onTabChange('tahesab')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:arrow-right" :size="14" />
|
||||
تهحساب ← پنل
|
||||
<span v-if="status?.tahesab_only_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.tahesab_only_count }}</span>
|
||||
از تهحساب به پنل
|
||||
<span v-if="status?.tahesab_importable_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.tahesab_importable_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -100,16 +99,18 @@
|
||||
class="cs-search"
|
||||
@change="onPanelSearch"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingPanel"
|
||||
:disabled="!status?.panel_only"
|
||||
@click="syncAllPanel"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
|
||||
ثبت همه در تهحساب
|
||||
</a-button>
|
||||
<div class="cs-card__head-btns">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingPanel"
|
||||
:disabled="!status?.panel_only"
|
||||
@click="syncAllPanel"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
|
||||
ثبت همه در تهحساب
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,6 +192,28 @@
|
||||
|
||||
<!-- ─── تب ۲: تهحساب → پنل ─── -->
|
||||
<div v-show="activeTab === 'tahesab'" class="cs-card">
|
||||
<div
|
||||
v-if="(status?.phone_match_count || 0) > 0 || (status?.phone_conflict_count || 0) > 0"
|
||||
class="cs-link-banner"
|
||||
>
|
||||
<div class="cs-link-banner__copy">
|
||||
<p v-if="status?.phone_match_count" class="cs-link-banner__title">
|
||||
{{ status.phone_match_count }} مشتری با شماره یکسان در پنل هست — کد تهحساب روی همان حساب مینشیند.
|
||||
</p>
|
||||
<p v-if="status?.phone_conflict_count" class="cs-link-banner__warn">
|
||||
{{ status.phone_conflict_count }} مورد تداخل کد (شماره یکی، کد فرق دارد) — از صفحهٔ مشتری تصمیم بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="status?.phone_match_count"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="linkingPhones"
|
||||
@click="confirmLinkPhones"
|
||||
>
|
||||
نشاندن کد روی شمارههای یکسان
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="cs-card__head">
|
||||
<div class="cs-card__head-right">
|
||||
<span class="cs-dot cs-dot--sky" />
|
||||
@@ -205,24 +228,26 @@
|
||||
class="cs-search"
|
||||
@change="onTahesabSearch"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingTahesab"
|
||||
:disabled="!status?.tahesab_only_count || selectedTahesab.size === 0"
|
||||
@click="importSelected"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
|
||||
وارد کردن انتخابشدهها ({{ selectedTahesab.size }})
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="importStore.active"
|
||||
:disabled="!filteredTahesabList.length"
|
||||
@click="importAll"
|
||||
>
|
||||
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${status?.tahesab_only_count ?? 0})` }}
|
||||
</a-button>
|
||||
<div class="cs-card__head-btns">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingTahesab"
|
||||
:disabled="importableSelectedCount === 0"
|
||||
@click="importSelected"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
|
||||
وارد کردن انتخابشده ({{ importableSelectedCount }})
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="importStore.active"
|
||||
:disabled="!importableCount"
|
||||
@click="importAll"
|
||||
>
|
||||
{{ importStore.active ? 'در حال…' : `وارد کردن همه (${importableCount})` }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,21 +276,24 @@
|
||||
<div
|
||||
v-for="c in pagedTahesab" :key="c.code"
|
||||
class="cs-mobile-row"
|
||||
:class="{ 'cs-mobile-row--done': tahesabDone.has(c.code) }"
|
||||
:class="{
|
||||
'cs-mobile-row--done': tahesabDone.has(c.code),
|
||||
'cs-mobile-row--no-phone': !rowHasPhone(c),
|
||||
}"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="selectedTahesab.has(c.code)"
|
||||
:disabled="tahesabDone.has(c.code)"
|
||||
:disabled="!rowHasPhone(c) || tahesabDone.has(c.code)"
|
||||
class="cs-mobile-row__check"
|
||||
@change="toggleTahesab(c.code)"
|
||||
/>
|
||||
<div class="cs-mobile-row__info">
|
||||
<div class="cs-mobile-row__name">{{ c.name }}</div>
|
||||
<div class="cs-mobile-row__mobile">{{ c.tel || '—' }} · کد {{ c.code }}</div>
|
||||
<div class="cs-mobile-row__mobile">{{ c.tel || 'بدون شماره' }} · کد {{ c.code }} · {{ formatMande(c) }}</div>
|
||||
</div>
|
||||
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
|
||||
<a-button v-else type="primary" ghost size="small"
|
||||
@click="importOne(c)">
|
||||
<span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
|
||||
<a-button v-else type="primary" ghost size="small" @click="importOne(c)">
|
||||
وارد کردن
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -274,25 +302,30 @@
|
||||
<table class="cs-table">
|
||||
<thead><tr>
|
||||
<th><a-checkbox :checked="allPageSelected" :indeterminate="someSelected" @change="toggleAll" /></th>
|
||||
<th>#</th><th>نام مشتری</th><th>شماره تلفن</th><th>کد تهحساب</th><th>گروه</th><th>عملیات</th>
|
||||
<th>#</th><th>نام مشتری</th><th>شماره تلفن</th><th>مانده</th><th>کد تهحساب</th><th>گروه</th><th>عملیات</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, idx) in pagedTahesab" :key="c.code"
|
||||
:class="{ 'cs-table__row--done': tahesabDone.has(c.code) }">
|
||||
:class="{
|
||||
'cs-table__row--done': tahesabDone.has(c.code),
|
||||
'cs-table__row--no-phone': !rowHasPhone(c),
|
||||
}">
|
||||
<td>
|
||||
<a-checkbox
|
||||
:checked="selectedTahesab.has(c.code)"
|
||||
:disabled="tahesabDone.has(c.code)"
|
||||
:disabled="!rowHasPhone(c) || tahesabDone.has(c.code)"
|
||||
@change="toggleTahesab(c.code)"
|
||||
/>
|
||||
</td>
|
||||
<td class="cs-table__num">{{ (tahesabPage - 1) * 20 + idx + 1 }}</td>
|
||||
<td class="cs-table__name">{{ c.name }}</td>
|
||||
<td class="cs-table__mobile">{{ c.tel || '—' }}</td>
|
||||
<td class="cs-table__mande">{{ formatMande(c) }}</td>
|
||||
<td class="cs-table__mobile">{{ c.code }}</td>
|
||||
<td class="cs-table__date">{{ c.group || '—' }}</td>
|
||||
<td class="cs-table__action">
|
||||
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
|
||||
<span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
|
||||
<a-button v-else type="primary" ghost size="small" @click="importOne(c)">
|
||||
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
|
||||
وارد کردن
|
||||
@@ -322,8 +355,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue/es'
|
||||
import { ref, computed, onMounted, reactive, watch, createVNode } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue/es'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import {
|
||||
@@ -331,6 +365,8 @@ import {
|
||||
syncPanelToTahesab,
|
||||
importFromTahesab,
|
||||
registerCustomerInTahesab,
|
||||
linkTahesabMatchingPhones,
|
||||
fetchTahesabMandeByCodes,
|
||||
type CustomerSyncStatus,
|
||||
type PanelOnlyCustomer,
|
||||
type TahesabOnlyCustomer,
|
||||
@@ -338,7 +374,8 @@ import {
|
||||
} from '@/api/tahesab'
|
||||
import { useCustomerImportStore } from '@/store/customerImportStore'
|
||||
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
|
||||
import TahesabWalletBulkSync from '@/pages/menuPages/accounting/components/TahesabWalletBulkSync.vue'
|
||||
import { accountingTahesabCustomerSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
|
||||
const importStore = useCustomerImportStore()
|
||||
|
||||
@@ -369,6 +406,40 @@ const tahesabDone = reactive(new Set<number>())
|
||||
const selectedTahesab = reactive(new Set<number>())
|
||||
const tahesabSyncResult = ref<SyncPanelResult | null>(null)
|
||||
const showTahesabErrors = ref(false)
|
||||
const linkingPhones = ref(false)
|
||||
|
||||
function rowHasPhone(c: TahesabOnlyCustomer) {
|
||||
if (c.has_phone === false) return false
|
||||
if (c.has_phone === true) return true
|
||||
const tel = (c.tel || '').replace(/\D/g, '')
|
||||
return tel.length >= 10
|
||||
}
|
||||
|
||||
const importableList = computed(() =>
|
||||
filteredTahesabList.value.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code)),
|
||||
)
|
||||
const importableCount = computed(() => importableList.value.length)
|
||||
const importableSelectedCount = computed(() =>
|
||||
[...selectedTahesab].filter(code => {
|
||||
const c = tahesabOnlyList.value.find(x => x.code === code)
|
||||
return c && rowHasPhone(c) && !tahesabDone.has(code)
|
||||
}).length,
|
||||
)
|
||||
|
||||
function formatGold(v?: number | null) {
|
||||
if (v == null || Number.isNaN(Number(v))) return ''
|
||||
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
}
|
||||
function formatCash(v?: number | null) {
|
||||
if (v == null || Number.isNaN(Number(v))) return ''
|
||||
return Math.round(Number(v)).toLocaleString('fa-IR')
|
||||
}
|
||||
function formatMande(c: TahesabOnlyCustomer) {
|
||||
const g = formatGold(c.gold)
|
||||
const r = formatCash(c.cash)
|
||||
if (!g && !r) return '—'
|
||||
return [g ? `${g} طلا` : '', r ? `${r} ریال` : ''].filter(Boolean).join(' / ')
|
||||
}
|
||||
|
||||
// ─── computed ───
|
||||
const registeredPercent = computed(() => {
|
||||
@@ -407,12 +478,12 @@ const pagedTahesab = computed(() => {
|
||||
return filteredTahesabList.value.slice(s, s + 20)
|
||||
})
|
||||
|
||||
const allPageSelected = computed(() =>
|
||||
pagedTahesab.value.length > 0 &&
|
||||
pagedTahesab.value.every(c => selectedTahesab.has(c.code) || tahesabDone.has(c.code))
|
||||
)
|
||||
const allPageSelected = computed(() => {
|
||||
const importable = pagedTahesab.value.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
|
||||
return importable.length > 0 && importable.every(c => selectedTahesab.has(c.code))
|
||||
})
|
||||
const someSelected = computed(() =>
|
||||
pagedTahesab.value.some(c => selectedTahesab.has(c.code)) && !allPageSelected.value
|
||||
pagedTahesab.value.some(c => selectedTahesab.has(c.code) && rowHasPhone(c)) && !allPageSelected.value
|
||||
)
|
||||
|
||||
// ─── load panel only (سریع — بدون Tahesab API) ───
|
||||
@@ -453,11 +524,19 @@ async function loadTahesab() {
|
||||
if (status.value) {
|
||||
status.value.tahesab_total = res.tahesab_total ?? 0
|
||||
status.value.tahesab_only_count = res.tahesab_only_count ?? 0
|
||||
status.value.tahesab_with_phone_count = res.tahesab_with_phone_count ?? 0
|
||||
status.value.tahesab_without_phone_count = res.tahesab_without_phone_count ?? 0
|
||||
status.value.tahesab_importable_count = res.tahesab_importable_count ?? 0
|
||||
status.value.phone_match_count = res.phone_match_count ?? 0
|
||||
status.value.phone_conflict_count = res.phone_conflict_count ?? 0
|
||||
status.value.phone_matches = res.phone_matches ?? []
|
||||
status.value.phone_conflicts = res.phone_conflicts ?? []
|
||||
} else {
|
||||
status.value = res
|
||||
}
|
||||
tahesabOnlyList.value = res.tahesab_only_list?.data ?? []
|
||||
tahesabFetched.value = true
|
||||
await enrichPageMande()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loadingTahesab.value = false }
|
||||
}
|
||||
@@ -528,31 +607,70 @@ async function syncAllPanel() {
|
||||
|
||||
// ─── تب ۲: عملیات ───
|
||||
function toggleTahesab(code: number) {
|
||||
const row = tahesabOnlyList.value.find(c => c.code === code)
|
||||
if (row && !rowHasPhone(row)) return
|
||||
if (selectedTahesab.has(code)) selectedTahesab.delete(code)
|
||||
else selectedTahesab.add(code)
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const importable = pagedTahesab.value.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
|
||||
if (allPageSelected.value) {
|
||||
pagedTahesab.value.forEach(c => selectedTahesab.delete(c.code))
|
||||
importable.forEach(c => selectedTahesab.delete(c.code))
|
||||
} else {
|
||||
pagedTahesab.value.forEach(c => { if (!tahesabDone.has(c.code)) selectedTahesab.add(c.code) })
|
||||
importable.forEach(c => selectedTahesab.add(c.code))
|
||||
}
|
||||
}
|
||||
|
||||
function importReport(customers: TahesabOnlyCustomer[]) {
|
||||
const withPhone = customers.filter(rowHasPhone)
|
||||
const noPhone = customers.length - withPhone.length
|
||||
return [
|
||||
`${withPhone.length} مشتری با شماره وارد پنل میشوند.`,
|
||||
noPhone ? `${noPhone} بدون شماره وارد نمیشوند.` : '',
|
||||
'مانده تهحساب روی کیف خالی مینشیند. اگر اپ مانده داشته باشد برای تصمیم شما میماند.',
|
||||
'دو مانده با هم جمع نمیشوند.',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function confirmImport(customers: TahesabOnlyCustomer[], onOk: () => void) {
|
||||
const ready = customers.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
|
||||
if (!ready.length) {
|
||||
message.warning('مشتری دارای شماره برای وارد کردن نیست')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'وارد کردن به پنل؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: importReport(customers),
|
||||
okText: 'بله، وارد کن',
|
||||
cancelText: 'انصراف',
|
||||
centered: true,
|
||||
onOk,
|
||||
})
|
||||
}
|
||||
|
||||
async function doImport(customers: TahesabOnlyCustomer[]) {
|
||||
const ready = customers.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
|
||||
if (!ready.length) {
|
||||
message.warning('بدون شماره وارد پنل نمیشوند')
|
||||
return
|
||||
}
|
||||
syncingTahesab.value = true
|
||||
tahesabSyncResult.value = null
|
||||
showTahesabErrors.value = false
|
||||
try {
|
||||
const res = await importFromTahesab(customers)
|
||||
const res = await importFromTahesab(ready)
|
||||
tahesabSyncResult.value = res
|
||||
if (res.success) {
|
||||
message.success(`${res.success} مشتری وارد پنل شد`)
|
||||
customers.forEach(c => { if (!res.errors.find((e: any) => e.code === c.code)) tahesabDone.add(c.code) })
|
||||
ready.forEach(c => { if (!res.errors.find((e: any) => e.code === c.code)) tahesabDone.add(c.code) })
|
||||
if (status.value?.tahesab_only_count != null) {
|
||||
status.value.tahesab_only_count = Math.max(0, status.value.tahesab_only_count - res.success)
|
||||
}
|
||||
if (status.value?.tahesab_importable_count != null) {
|
||||
status.value.tahesab_importable_count = Math.max(0, status.value.tahesab_importable_count - res.success)
|
||||
}
|
||||
}
|
||||
if (res.failed) message.warning(`${res.failed} مشتری وارد نشد`)
|
||||
await loadPanel(panelPagination.value.page)
|
||||
@@ -561,20 +679,73 @@ async function doImport(customers: TahesabOnlyCustomer[]) {
|
||||
finally { syncingTahesab.value = false }
|
||||
}
|
||||
|
||||
async function importOne(customer: TahesabOnlyCustomer) { await doImport([customer]) }
|
||||
async function importSelected() { await doImport(filteredTahesabList.value.filter(c => selectedTahesab.has(c.code))) }
|
||||
async function importOne(customer: TahesabOnlyCustomer) {
|
||||
if (!rowHasPhone(customer)) {
|
||||
message.warning('بدون شماره از تهحساب وارد پنل نمیشود')
|
||||
return
|
||||
}
|
||||
confirmImport([customer], () => doImport([customer]))
|
||||
}
|
||||
async function importSelected() {
|
||||
const rows = filteredTahesabList.value.filter(c => selectedTahesab.has(c.code))
|
||||
confirmImport(rows, () => doImport(rows))
|
||||
}
|
||||
|
||||
// «وارد کردن همه» با پروگرسبار سراسری و قابل مینیمایز (پسزمینهای)
|
||||
async function importAll() {
|
||||
const pending = filteredTahesabList.value.filter(c => !tahesabDone.has(c.code))
|
||||
const pending = importableList.value
|
||||
if (!pending.length) return
|
||||
if (importStore.active) {
|
||||
importStore.expand()
|
||||
return
|
||||
}
|
||||
await importStore.start(pending, 'tahesab')
|
||||
confirmImport(pending, () => importStore.start(pending, 'tahesab'))
|
||||
}
|
||||
|
||||
async function confirmLinkPhones() {
|
||||
Modal.confirm({
|
||||
title: 'نشاندن کد تهحساب روی شماره یکسان؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: 'کد فقط روی مشتری پنل بدون کد مینشیند. اگر هر دو مانده داشته باشند برای تصمیم میماند. کد متفاوت دستی حل میشود.',
|
||||
okText: 'اعمال کد',
|
||||
cancelText: 'انصراف',
|
||||
centered: true,
|
||||
onOk: linkPhones,
|
||||
})
|
||||
}
|
||||
|
||||
async function linkPhones() {
|
||||
linkingPhones.value = true
|
||||
try {
|
||||
const res = await linkTahesabMatchingPhones()
|
||||
if (res.linked) message.success(res.message)
|
||||
else message.info(res.message)
|
||||
if (res.conflicts) message.warning(`${res.conflicts} تداخل کد — دستی تصمیم بگیرید`)
|
||||
tahesabFetched.value = false
|
||||
await loadTahesab()
|
||||
await loadPanel(panelPagination.value.page)
|
||||
} catch {
|
||||
message.error('لینک شماره یکسان ناموفق بود')
|
||||
} finally {
|
||||
linkingPhones.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichPageMande() {
|
||||
const need = pagedTahesab.value.filter(c => c.gold == null && c.cash == null && c.code)
|
||||
if (!need.length) return
|
||||
try {
|
||||
const res = await fetchTahesabMandeByCodes(need.map(c => c.code))
|
||||
const mandes = res.mandes || {}
|
||||
tahesabOnlyList.value = tahesabOnlyList.value.map((c) => {
|
||||
const m = mandes[String(c.code)]
|
||||
if (!m) return c
|
||||
return { ...c, gold: m.gold, cash: m.cash }
|
||||
})
|
||||
} catch { /* ستون مانده اختیاری است */ }
|
||||
}
|
||||
|
||||
watch(pagedTahesab, () => { enrichPageMande() })
|
||||
|
||||
// با پیشرفت عملیات سراسری، ردیفهای وارد شده را همینجا هم تیک بزن
|
||||
watch(() => importStore.doneCodes.length, () => {
|
||||
importStore.doneCodes.forEach(code => tahesabDone.add(code))
|
||||
@@ -602,104 +773,108 @@ onMounted(() => {
|
||||
.cs-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 8px 4px 24px;
|
||||
gap: 10px;
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
/* ─── بنر ─── */
|
||||
/* ─── آمار فشرده ─── */
|
||||
.cs-banner {
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #0ea5e9 100%);
|
||||
padding: 14px 16px;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.35);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-bg-surface);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
@media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
|
||||
|
||||
&__title { font-size: 14px; font-weight: 700; }
|
||||
}
|
||||
|
||||
/* ردیف اصلی: پنل ↔ تهحساب */
|
||||
.cs-stats-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ردیف تفاوت */
|
||||
.cs-stats-diff {
|
||||
.cs-banner__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cs-banner__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.cs-banner__title-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cs-banner__meta {
|
||||
font-size: 11px;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.cs-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cs-sep-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: rgba(255,255,255,0.4);
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
@media (min-width: 640px) {
|
||||
.cs-stats {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.cs-stat {
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-bg-muted);
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&--neutral { background: rgba(255,255,255,0.12); }
|
||||
&--tahesab { background: rgba(14,165,233,0.25); }
|
||||
&--amber { background: rgba(251,191,36,0.2); }
|
||||
&--sky { background: rgba(14,165,233,0.2); }
|
||||
&--amber { border-color: rgba(245, 158, 11, 0.35); }
|
||||
&--sky { border-color: rgba(14, 165, 233, 0.35); }
|
||||
|
||||
&__num {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
min-height: 26px;
|
||||
&__num {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--admin-text);
|
||||
min-height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 640px) { font-size: 24px; }
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
margin-top: 3px;
|
||||
color: var(--admin-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
&__hint { font-size: 10px; color: rgba(255,255,255,0.4); font-weight: 400; }
|
||||
}
|
||||
|
||||
/* progress */
|
||||
.cs-progress {
|
||||
&__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.6);
|
||||
margin-bottom: 5px;
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: var(--admin-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.cs-track {
|
||||
height: 7px;
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
background: var(--admin-border);
|
||||
overflow: hidden;
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: #34d399;
|
||||
transition: width 0.7s ease;
|
||||
background: var(--admin-primary, #d4af37);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,7 +882,7 @@ onMounted(() => {
|
||||
.cs-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--admin-border);
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
@@ -717,26 +892,24 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 12px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 640px) { padding: 8px 16px; font-size: 13px; }
|
||||
|
||||
&:hover { color: var(--admin-text); }
|
||||
|
||||
&--active {
|
||||
color: #7c3aed;
|
||||
border-bottom-color: #7c3aed;
|
||||
color: var(--admin-primary, #d4af37);
|
||||
border-bottom-color: var(--admin-primary, #d4af37);
|
||||
}
|
||||
|
||||
&__badge {
|
||||
@@ -786,11 +959,41 @@ onMounted(() => {
|
||||
|
||||
&__head-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
.ant-btn { flex-shrink: 0; }
|
||||
@media (min-width: 640px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__head-btns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
display: flex;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,8 +1013,17 @@ onMounted(() => {
|
||||
&--sky { background: #0ea5e9; }
|
||||
}
|
||||
|
||||
.cs-search { width: 150px; }
|
||||
@media (max-width: 639px) { .cs-search { flex: 1; min-width: 0; } }
|
||||
/* سرچ: موبایل تمامعرض؛ دسکتاپ عرض متعادل کنار دکمهها */
|
||||
.cs-search {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
width: 11rem !important;
|
||||
max-width: 14rem;
|
||||
flex: 1 1 11rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── نتیجه sync ─── */
|
||||
.cs-result {
|
||||
@@ -847,6 +1059,7 @@ onMounted(() => {
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&--done { opacity: 0.45; }
|
||||
&--no-phone { opacity: 0.55; filter: grayscale(0.35); }
|
||||
|
||||
&__check { flex-shrink: 0; }
|
||||
&__info { flex: 1; min-width: 0; }
|
||||
@@ -864,9 +1077,11 @@ onMounted(() => {
|
||||
|
||||
thead tr { background: var(--admin-bg-muted); border-bottom: 1px solid var(--admin-border); }
|
||||
th { padding: 10px 14px; text-align: right; font-size: 11px; font-weight: 600; color: var(--admin-text-secondary); white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid var(--admin-border); transition: background 0.15s; }
|
||||
tbody tr { border-bottom: 1px solid var(--admin-border); background: var(--admin-bg-surface); transition: background 0.15s; }
|
||||
tbody tr:nth-child(even) { background: var(--admin-bg-surface); }
|
||||
tbody tr:hover { background: var(--admin-bg-hover); }
|
||||
tbody tr.cs-table__row--done { opacity: 0.45; }
|
||||
tbody tr.cs-table__row--no-phone { opacity: 0.55; filter: grayscale(0.35); }
|
||||
td { padding: 10px 14px; color: var(--admin-text); }
|
||||
|
||||
&__num { color: var(--admin-text-secondary); font-size: 12px; width: 40px; }
|
||||
@@ -874,6 +1089,46 @@ onMounted(() => {
|
||||
&__mobile { font-family: monospace; font-size: 12px; color: var(--admin-text-muted); }
|
||||
&__date { font-size: 12px; color: var(--admin-text-secondary); }
|
||||
&__action { text-align: center; white-space: nowrap; }
|
||||
&__mande { font-size: 11px; color: var(--admin-text-secondary); white-space: nowrap; }
|
||||
}
|
||||
|
||||
.cs-skip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(148,163,184,0.18);
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-link-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.cs-link-banner {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.cs-link-banner__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
.cs-link-banner__warn {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.cs-done-badge {
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
<template>
|
||||
<div class="tw-bulk">
|
||||
<div class="tw-bulk__head">
|
||||
<div class="tw-bulk__icon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:refresh-circle" :size="20" color="var(--admin-primary, #10913A)" />
|
||||
</div>
|
||||
<div class="tw-bulk__row">
|
||||
<div class="tw-bulk__copy min-w-0 flex-1">
|
||||
<p class="tw-bulk__title">همگامسازی ماندهٔ مشتریان لینکشده</p>
|
||||
<div class="tw-bulk__title-row">
|
||||
<p class="tw-bulk__title">همگامسازی مانده</p>
|
||||
<AdminFieldHelp :text="accountingTahesabBalanceSyncIntro" size="sm" />
|
||||
</div>
|
||||
<p class="tw-bulk__hint">
|
||||
موجودی کیف همهٔ مشتریان دارای کد تهحساب را با ماندهٔ تهحساب یکی میکند.
|
||||
اگر مشتری در اپ مانده داشته باشد و با تهحساب فرق کند، در جزئیات نشان داده میشود تا جداگانه تصمیم بگیرید.
|
||||
همگام تکی: اول سندها و اختلاف را میبینید، بعد تأیید میکنید. دو مانده جمع نمیشوند.
|
||||
همگام همه فقط کیف خالی را مینشاند؛ تداخل برای تصمیم دستی میماند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-bulk__actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
class="tw-bulk__btn"
|
||||
:loading="running && !overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(false)"
|
||||
>
|
||||
همگامسازی مانده (بدون بازنویسی تداخلها)
|
||||
</a-button>
|
||||
<a-button
|
||||
block
|
||||
danger
|
||||
class="tw-bulk__btn"
|
||||
:loading="running && overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(true)"
|
||||
>
|
||||
اعمال اجباری مانده تهحساب برای همه
|
||||
</a-button>
|
||||
<div class="tw-bulk__actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="running && !overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(false)"
|
||||
>
|
||||
همگامسازی
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
:loading="running && overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(true)"
|
||||
>
|
||||
اعمال اجباری
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="running || resultVisible" class="tw-bulk__progress">
|
||||
@@ -52,8 +49,8 @@
|
||||
<template v-else-if="resultVisible">
|
||||
<div class="tw-bulk__progress-row">
|
||||
<span class="tw-bulk__ok">✓ {{ success }} موفق</span>
|
||||
<span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر موجودی</span>
|
||||
<span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} نیازمند تصمیم</span>
|
||||
<span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر</span>
|
||||
<span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} تصمیم</span>
|
||||
<span v-if="failed" class="tw-bulk__fail">✗ {{ failed }} ناموفق</span>
|
||||
<a-button
|
||||
v-if="errors.length || decisions.length"
|
||||
@@ -66,12 +63,25 @@
|
||||
<a-button type="link" size="small" @click="dismissResult">بستن</a-button>
|
||||
</div>
|
||||
<div v-if="showDetails && decisions.length" class="tw-bulk__decisions">
|
||||
<div class="tw-bulk__decisions-title">تداخل موجودی — از صفحه مشتری تصمیم بگیرید:</div>
|
||||
<div v-for="d in decisions" :key="d.id">
|
||||
<b>{{ d.name }}</b>
|
||||
<template v-if="d.mobile"> ({{ d.mobile }})</template>
|
||||
— اپ: {{ formatGold(d.app_gold) }} طلا / {{ formatCash(d.app_cash) }} ریال
|
||||
· تهحساب: {{ formatGold(d.tahesab_gold) }} طلا / {{ formatCash(d.tahesab_cash) }} ریال
|
||||
<div class="tw-bulk__decisions-title">تداخل مانده — کدام را نگه داریم؟ (جمع نمیشود)</div>
|
||||
<div v-for="d in decisions" :key="d.id" class="tw-bulk__decision-row">
|
||||
<div>
|
||||
<b>{{ d.name }}</b>
|
||||
<template v-if="d.mobile"> ({{ d.mobile }})</template>
|
||||
— اپ: {{ formatGold(d.app_gold) }} طلا / {{ formatCash(d.app_cash) }} ریال
|
||||
· تهحساب: {{ formatGold(d.tahesab_gold) }} طلا / {{ formatCash(d.tahesab_cash) }} ریال
|
||||
</div>
|
||||
<div class="tw-bulk__decision-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="syncingId === d.id && syncingMode === 'ask'"
|
||||
:disabled="!!syncingId || !!decidingId"
|
||||
@click="openReview(d.id)"
|
||||
>
|
||||
بررسی و تصمیم
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showDetails && errors.length" class="tw-bulk__errors">
|
||||
@@ -83,18 +93,95 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="tw-bulk__toolbar">
|
||||
<a-input
|
||||
v-model:value="search"
|
||||
allow-clear
|
||||
size="small"
|
||||
placeholder="جستجوی نام، موبایل یا کد"
|
||||
class="tw-bulk__search"
|
||||
@change="onSearch"
|
||||
/>
|
||||
<span class="tw-bulk__count">{{ listTotal }} مشتری لینکشده</span>
|
||||
</div>
|
||||
|
||||
<div class="tw-bulk__table-wrap">
|
||||
<a-spin :spinning="listLoading">
|
||||
<a-table
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="'id'"
|
||||
:pagination="tablePagination"
|
||||
:locale="{ emptyText: search ? 'نتیجهای نیست' : 'مشتری لینکشدهای نیست' }"
|
||||
:scroll="{ x: 960 }"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'customer'">
|
||||
<div class="tw-bulk__cell-name">{{ record.name || '—' }}</div>
|
||||
<div class="tw-bulk__cell-sub">{{ record.mobile || '—' }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'app_gold'">{{ formatGold(record.app_gold) }}</template>
|
||||
<template v-else-if="column.key === 'th_gold'">{{ formatGold(record.tahesab_gold) }}</template>
|
||||
<template v-else-if="column.key === 'app_cash'">{{ formatCash(record.app_cash) }}</template>
|
||||
<template v-else-if="column.key === 'th_cash'">{{ formatCash(record.tahesab_cash) }}</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<div class="tw-bulk__row-actions">
|
||||
<a-button
|
||||
v-if="record.status === 'ready' || record.status === 'same' || record.status === 'conflict'"
|
||||
type="link"
|
||||
size="small"
|
||||
:loading="syncingId === record.id && syncingMode === 'ask'"
|
||||
:disabled="!!syncingId || running"
|
||||
@click="openReview(record.id)"
|
||||
>
|
||||
بررسی و تصمیم
|
||||
</a-button>
|
||||
<span v-if="record.status === 'missing'" class="tw-bulk__cell-sub">مانده نیست</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TahesabMandeDecisionModal
|
||||
v-model:visible="decisionVisible"
|
||||
source-label="تهحساب"
|
||||
:app-gold="decisionResult?.app_gold"
|
||||
:app-cash="decisionResult?.app_cash"
|
||||
:tahesab-gold="decisionResult?.tahesab_gold"
|
||||
:tahesab-cash="decisionResult?.tahesab_cash"
|
||||
:gold-delta="decisionResult?.gold_delta"
|
||||
:rial-delta="decisionResult?.rial_delta"
|
||||
:wallet-docs="decisionResult?.wallet_docs"
|
||||
:tahesab-docs="decisionResult?.tahesab_docs"
|
||||
@decide="onReviewDecide"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, createVNode, ref } from 'vue'
|
||||
import { computed, createVNode, onMounted, ref } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import {
|
||||
getTahesabWalletLinkedList,
|
||||
previewCustomerWalletFromTahesab,
|
||||
syncAllWalletsFromTahesabChunk,
|
||||
syncCustomerWalletFromTahesab,
|
||||
type TahesabBulkWalletSyncChunk,
|
||||
type TahesabWalletLinkedRow,
|
||||
type TahesabWalletSyncResult,
|
||||
} from '@/api/tahesab'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
import { accountingTahesabBalanceSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import TahesabMandeDecisionModal from '@/pages/menuPages/customers/base/components/TahesabMandeDecisionModal.vue'
|
||||
|
||||
const CHUNK_LIMIT = 20
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
@@ -112,26 +199,168 @@ const needsDecision = ref(0)
|
||||
const failed = ref(0)
|
||||
const errors = ref<TahesabBulkWalletSyncChunk['errors']>([])
|
||||
const decisions = ref<TahesabBulkWalletSyncChunk['decisions']>([])
|
||||
const decidingId = ref<number | null>(null)
|
||||
const decidingMode = ref<'overwrite' | 'keep' | null>(null)
|
||||
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const listTotal = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const rows = ref<TahesabWalletLinkedRow[]>([])
|
||||
const syncingId = ref<number | null>(null)
|
||||
const syncingMode = ref<'ask' | 'overwrite' | 'keep' | null>(null)
|
||||
const decisionVisible = ref(false)
|
||||
const decisionCustomerId = ref<number | null>(null)
|
||||
const decisionResult = ref<TahesabWalletSyncResult['result'] | null>(null)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const columns = [
|
||||
{ title: 'مشتری', key: 'customer', width: 160 },
|
||||
{ title: 'کد', dataIndex: 'moshtari_code', key: 'code', width: 72 },
|
||||
{ title: 'طلا اپ', key: 'app_gold', width: 100 },
|
||||
{ title: 'طلا تهحساب', key: 'th_gold', width: 110 },
|
||||
{ title: 'ریال اپ', key: 'app_cash', width: 110 },
|
||||
{ title: 'ریال تهحساب', key: 'th_cash', width: 120 },
|
||||
{ title: 'وضعیت', key: 'status', width: 110 },
|
||||
{ title: 'عمل', key: 'action', width: 180 },
|
||||
]
|
||||
|
||||
const tablePagination = computed(() => ({
|
||||
current: page.value,
|
||||
pageSize: perPage.value,
|
||||
total: listTotal.value,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (n: number) => `${n} ردیف`,
|
||||
}))
|
||||
|
||||
const percent = computed(() =>
|
||||
total.value > 0 ? Math.min(100, Math.round((processed.value / total.value) * 100)) : 0,
|
||||
)
|
||||
|
||||
function formatGold(v?: number) {
|
||||
function formatGold(v?: number | null) {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
}
|
||||
|
||||
function formatCash(v?: number) {
|
||||
function formatCash(v?: number | null) {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return Math.round(Number(v)).toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
function statusLabel(status: TahesabWalletLinkedRow['status']) {
|
||||
if (status === 'same') return 'یکسان'
|
||||
if (status === 'ready') return 'قابل اعمال'
|
||||
if (status === 'missing') return 'بدون مانده'
|
||||
return 'تداخل'
|
||||
}
|
||||
|
||||
function statusColor(status: TahesabWalletLinkedRow['status']) {
|
||||
if (status === 'same') return 'green'
|
||||
if (status === 'ready') return 'blue'
|
||||
if (status === 'missing') return 'default'
|
||||
return 'orange'
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getTahesabWalletLinkedList({
|
||||
search: search.value.trim(),
|
||||
page: page.value,
|
||||
per_page: perPage.value,
|
||||
})
|
||||
rows.value = res.rows ?? []
|
||||
listTotal.value = res.total ?? 0
|
||||
page.value = res.page ?? page.value
|
||||
} catch {
|
||||
message.error('بارگذاری لیست مانده ناموفق بود')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadList()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
page.value = pag.current ?? 1
|
||||
perPage.value = pag.pageSize ?? perPage.value
|
||||
void loadList()
|
||||
}
|
||||
|
||||
async function openReview(customerId: number) {
|
||||
if (syncingId.value || running.value || decidingId.value) return
|
||||
syncingId.value = customerId
|
||||
syncingMode.value = 'ask'
|
||||
try {
|
||||
const res = await previewCustomerWalletFromTahesab(customerId)
|
||||
if (!res.ok && !res.result?.ok) {
|
||||
message.warning(res.message || 'مانده خوانده نشد')
|
||||
return
|
||||
}
|
||||
if (!res.needs_decision && !res.result?.needs_decision) {
|
||||
message.info(res.message || 'مانده یکسان است')
|
||||
await loadList()
|
||||
return
|
||||
}
|
||||
decisionCustomerId.value = customerId
|
||||
decisionResult.value = res.result ?? null
|
||||
decisionVisible.value = true
|
||||
} catch {
|
||||
message.error('خواندن مانده این مشتری ناموفق بود')
|
||||
} finally {
|
||||
syncingId.value = null
|
||||
syncingMode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onReviewDecide(mode: 'overwrite' | 'keep') {
|
||||
const customerId = decisionCustomerId.value
|
||||
decisionVisible.value = false
|
||||
if (!customerId) return
|
||||
await applyDecision(customerId, mode)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadList()
|
||||
})
|
||||
|
||||
function dismissResult() {
|
||||
resultVisible.value = false
|
||||
showDetails.value = false
|
||||
}
|
||||
|
||||
async function applyDecision(customerId: number, mode: 'overwrite' | 'keep') {
|
||||
decidingId.value = customerId
|
||||
decidingMode.value = mode
|
||||
try {
|
||||
const res = await syncCustomerWalletFromTahesab(customerId, {
|
||||
overwrite: mode === 'overwrite',
|
||||
keep: mode === 'keep',
|
||||
ask_if_conflict: false,
|
||||
})
|
||||
decisions.value = decisions.value.filter(d => d.id !== customerId)
|
||||
needsDecision.value = Math.max(0, decisions.value.length)
|
||||
if (mode === 'keep') message.info(res.message || 'موجودی اپ حفظ شد')
|
||||
else message.success(res.message || 'مانده تهحساب اعمال شد')
|
||||
await loadList()
|
||||
} catch {
|
||||
message.error('اعمال تصمیم ناموفق بود')
|
||||
} finally {
|
||||
decidingId.value = null
|
||||
decidingMode.value = null
|
||||
decisionCustomerId.value = null
|
||||
decisionResult.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAndStart(overwrite: boolean) {
|
||||
if (running.value) return
|
||||
Modal.confirm({
|
||||
@@ -140,8 +369,8 @@ function confirmAndStart(overwrite: boolean) {
|
||||
: 'همگامسازی ماندهٔ همهٔ مشتریان؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: overwrite
|
||||
? 'موجودی کیف همهٔ مشتریان دارای کد تهحساب با ماندهٔ تهحساب جایگزین میشود — حتی اگر در اپ مانده داشته باشند.'
|
||||
: 'برای مشتریانی که کیف خالی دارند مانده تهحساب مینشیند. اگر اپ مانده داشته باشد و فرق کند، در لیست «نیازمند تصمیم» میآید.',
|
||||
? 'موجودی کیف همهٔ مشتریان دارای کد تهحساب با ماندهٔ تهحساب جایگزین میشود. دو مانده جمع نمیشوند. این کار برگشتپذیر نیست مگر با سند جدید.'
|
||||
: 'برای مشتریانی که کیف خالی دارند مانده تهحساب مینشیند. اگر اپ مانده داشته باشد و فرق کند، جمع نمیشود و در لیست «نیازمند تصمیم» میآید تا سندها را ببینید و تأیید کنید.',
|
||||
okText: overwrite ? 'بله، برای همه اعمال کن' : 'شروع همگامسازی',
|
||||
okType: overwrite ? 'danger' : 'primary',
|
||||
cancelText: 'انصراف',
|
||||
@@ -213,45 +442,55 @@ async function start(overwrite: boolean) {
|
||||
} finally {
|
||||
running.value = false
|
||||
overwriteMode.value = false
|
||||
void loadList()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tw-bulk {
|
||||
@apply rounded-xl border border-base p-3;
|
||||
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc));
|
||||
@apply flex min-h-0 flex-col gap-2.5 rounded-lg border border-base p-3;
|
||||
background: var(--admin-bg-surface, var(--admin-bg-elevated));
|
||||
}
|
||||
|
||||
.tw-bulk__head {
|
||||
@apply mb-3 flex items-start gap-2.5;
|
||||
/* موبایل: ستون — دکمهها زیر توضیح | دسکتاپ: ردیف */
|
||||
.tw-bulk__row {
|
||||
@apply flex flex-col items-stretch gap-2.5;
|
||||
}
|
||||
|
||||
.tw-bulk__icon {
|
||||
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-50;
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__row {
|
||||
@apply flex-row items-center gap-3;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__title {
|
||||
@apply m-0 text-sm font-700;
|
||||
@apply m-0 text-sm font-600;
|
||||
color: var(--admin-text, inherit);
|
||||
}
|
||||
|
||||
.tw-bulk__title-row {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.tw-bulk__hint {
|
||||
@apply m-0 mt-0.5 text-xs leading-5;
|
||||
color: var(--admin-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.tw-bulk__actions {
|
||||
@apply flex flex-col gap-2;
|
||||
@apply flex flex-wrap items-center gap-1.5;
|
||||
}
|
||||
|
||||
.tw-bulk__btn {
|
||||
@apply font-600;
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__actions {
|
||||
@apply shrink-0;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__progress {
|
||||
@apply mt-3 rounded-lg border border-base p-2.5 text-xs;
|
||||
background: var(--admin-bg-surface, #fff);
|
||||
@apply mt-2 rounded-md border border-base p-2 text-xs;
|
||||
background: var(--admin-bg-muted, transparent);
|
||||
color: var(--admin-text, inherit);
|
||||
}
|
||||
|
||||
@@ -260,54 +499,100 @@ async function start(overwrite: boolean) {
|
||||
}
|
||||
|
||||
.tw-bulk__track {
|
||||
@apply mt-2 h-1.5 overflow-hidden rounded-full;
|
||||
@apply mt-1.5 h-1 overflow-hidden rounded-full;
|
||||
background: var(--admin-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.tw-bulk__track-fill {
|
||||
@apply h-full rounded-full transition-all duration-300;
|
||||
background: var(--admin-primary, #10913A);
|
||||
background: var(--admin-primary, #d4af37);
|
||||
}
|
||||
|
||||
.tw-bulk__ok {
|
||||
@apply rounded-full px-2.5 py-0.5 font-600;
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.tw-bulk__changed {
|
||||
@apply rounded-full px-2.5 py-0.5 font-600;
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.tw-bulk__decision {
|
||||
@apply rounded-full px-2.5 py-0.5 font-600;
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tw-bulk__fail {
|
||||
@apply rounded-full px-2.5 py-0.5 font-600;
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tw-bulk__decisions {
|
||||
@apply mt-2 max-h-36 overflow-y-auto rounded-lg p-2 text-xs leading-6;
|
||||
@apply mt-1.5 max-h-32 overflow-y-auto rounded-md p-2 text-xs leading-6;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.tw-bulk__decisions-title {
|
||||
@apply mb-1 font-700;
|
||||
.tw-bulk__decision-row {
|
||||
@apply mb-2 flex flex-col gap-1.5 border-b pb-2 last:mb-0 last:border-0 last:pb-0;
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
.tw-bulk__decision-actions {
|
||||
@apply flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.tw-bulk__errors {
|
||||
@apply mt-2 max-h-28 overflow-y-auto rounded-lg p-2 text-xs leading-6;
|
||||
@apply mt-1.5 max-h-24 overflow-y-auto rounded-md p-2 text-xs leading-6;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tw-bulk__toolbar {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__toolbar {
|
||||
@apply flex-row items-center gap-2.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__search {
|
||||
@apply w-full min-w-0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__search {
|
||||
@apply max-w-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__count {
|
||||
@apply text-xs;
|
||||
color: var(--admin-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.tw-bulk__table-wrap {
|
||||
@apply min-w-0 overflow-x-auto;
|
||||
}
|
||||
|
||||
.tw-bulk__cell-name {
|
||||
@apply text-xs font-600;
|
||||
}
|
||||
|
||||
.tw-bulk__cell-sub {
|
||||
@apply text-xs;
|
||||
color: var(--admin-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.tw-bulk__row-actions {
|
||||
@apply flex flex-wrap gap-0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
<!-- ─── بنر آمار ─── -->
|
||||
<div class="cs-banner">
|
||||
<div class="cs-banner__title">همگامسازی مشتریان</div>
|
||||
<div class="cs-banner__title-row">
|
||||
<div class="cs-banner__title">همگامسازی مشتریان</div>
|
||||
<AdminFieldHelp :text="accountingZarinCustomerSyncIntro" size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- آمار اصلی: ردیف بالا -->
|
||||
<div class="cs-stats-main">
|
||||
@@ -16,17 +19,21 @@
|
||||
</div>
|
||||
<div class="cs-stat__label">مشتریان پنل</div>
|
||||
</div>
|
||||
<div class="cs-sep-arrows">
|
||||
<Icon icon="vuesax-linear:arrow-left" :size="16" />
|
||||
<Icon icon="vuesax-linear:arrow-right" :size="16" />
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--zarin cs-stat--clickable" role="button" tabindex="0" @click="onTabChange('zarin')" @keydown.enter="onTabChange('zarin')">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status?.zarin_total != null">{{ status.zarin_total }}</template>
|
||||
<a-spin v-else-if="loadingZarin" size="small" />
|
||||
<span v-else class="cs-stat__hint">کلیک کنید</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">مشتریان زرین</div>
|
||||
<div class="cs-stat__label">کل زرین</div>
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--sky">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status?.zarin_with_phone_count != null">{{ status.zarin_with_phone_count }}</template>
|
||||
<a-spin v-else-if="loadingZarin" size="small" />
|
||||
<span v-else class="cs-stat__hint">—</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">زرین با شماره</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,11 +49,11 @@
|
||||
</div>
|
||||
<div class="cs-stat cs-stat--sky cs-stat--clickable" role="button" tabindex="0" @click="onTabChange('zarin')" @keydown.enter="onTabChange('zarin')">
|
||||
<div class="cs-stat__num">
|
||||
<template v-if="status?.zarin_only_count != null">{{ status.zarin_only_count }}</template>
|
||||
<template v-if="status?.zarin_importable_count != null">{{ status.zarin_importable_count }}</template>
|
||||
<a-spin v-else-if="loadingZarin" size="small" />
|
||||
<span v-else class="cs-stat__hint">کلیک کنید</span>
|
||||
</div>
|
||||
<div class="cs-stat__label">زرین — بدون پنل</div>
|
||||
<div class="cs-stat__label">قابل وارد کردن</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,8 +74,7 @@
|
||||
:class="{ 'cs-tab--active': activeTab === 'panel' }"
|
||||
@click="onTabChange('panel')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:arrow-left" :size="14" />
|
||||
پنل ← زرین
|
||||
از پنل به زرین
|
||||
<span v-if="status?.panel_only" class="cs-tab__badge cs-tab__badge--amber">{{ status.panel_only }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -76,9 +82,8 @@
|
||||
:class="{ 'cs-tab--active': activeTab === 'zarin' }"
|
||||
@click="onTabChange('zarin')"
|
||||
>
|
||||
<Icon icon="vuesax-linear:arrow-right" :size="14" />
|
||||
زرین ← پنل
|
||||
<span v-if="status?.zarin_only_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.zarin_only_count }}</span>
|
||||
از زرین به پنل
|
||||
<span v-if="status?.zarin_importable_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.zarin_importable_count }}</span>
|
||||
<span v-else-if="!zarinFetched && !loadingZarin" class="cs-tab__hint">بارگذاری</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,6 +195,28 @@
|
||||
|
||||
<!-- ─── تب ۲: زرین → پنل ─── -->
|
||||
<div v-show="activeTab === 'zarin'" class="cs-card">
|
||||
<div
|
||||
v-if="(status?.phone_match_count || 0) > 0 || (status?.phone_conflict_count || 0) > 0"
|
||||
class="cs-link-banner"
|
||||
>
|
||||
<div class="cs-link-banner__copy">
|
||||
<p v-if="status?.phone_match_count" class="cs-link-banner__title">
|
||||
{{ status.phone_match_count }} مشتری با شماره یکسان در پنل هست — کد زرین روی همان حساب مینشیند.
|
||||
</p>
|
||||
<p v-if="status?.phone_conflict_count" class="cs-link-banner__warn">
|
||||
{{ status.phone_conflict_count }} مورد تداخل کد — از صفحهٔ مشتری تصمیم بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="status?.phone_match_count"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="linkingPhones"
|
||||
@click="confirmLinkPhones"
|
||||
>
|
||||
نشاندن کد روی شمارههای یکسان
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="cs-card__head">
|
||||
<div class="cs-card__head-right">
|
||||
<span class="cs-dot cs-dot--sky" />
|
||||
@@ -204,19 +231,19 @@
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="syncingZarin"
|
||||
:disabled="!status?.zarin_only_count || selectedZarin.size === 0"
|
||||
:disabled="importableSelectedCount === 0"
|
||||
@click="importSelected"
|
||||
>
|
||||
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
|
||||
وارد کردن انتخابشدهها ({{ selectedZarin.size }})
|
||||
وارد کردن انتخابشده ({{ importableSelectedCount }})
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="importStore.active"
|
||||
:disabled="!zarinOnlyList.length"
|
||||
:disabled="!importableCount"
|
||||
@click="importAll"
|
||||
>
|
||||
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${status?.zarin_only_count ?? 0})` }}
|
||||
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${importableCount})` }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,21 +291,24 @@
|
||||
<div
|
||||
v-for="c in pagedZarin" :key="c.code"
|
||||
class="cs-mobile-row"
|
||||
:class="{ 'cs-mobile-row--done': zarinDone.has(c.code) }"
|
||||
:class="{
|
||||
'cs-mobile-row--done': zarinDone.has(c.code),
|
||||
'cs-mobile-row--no-phone': !rowHasPhone(c),
|
||||
}"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="selectedZarin.has(c.code)"
|
||||
:disabled="zarinDone.has(c.code)"
|
||||
:disabled="!rowHasPhone(c) || zarinDone.has(c.code)"
|
||||
class="cs-mobile-row__check"
|
||||
@change="toggleZarin(c.code)"
|
||||
/>
|
||||
<div class="cs-mobile-row__info">
|
||||
<div class="cs-mobile-row__name">{{ c.name }}</div>
|
||||
<div class="cs-mobile-row__mobile">{{ c.tel || '—' }} · کد {{ c.code }}</div>
|
||||
<div class="cs-mobile-row__mobile">{{ c.tel || 'بدون شماره' }} · کد {{ c.code }} · {{ formatMande(c) }}</div>
|
||||
</div>
|
||||
<span v-if="zarinDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
|
||||
<a-button v-else type="primary" ghost size="small"
|
||||
@click="importOne(c)">
|
||||
<span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
|
||||
<a-button v-else type="primary" ghost size="small" @click="importOne(c)">
|
||||
وارد کردن
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -287,25 +317,30 @@
|
||||
<table class="cs-table">
|
||||
<thead><tr>
|
||||
<th><a-checkbox :checked="allPageSelected" :indeterminate="someSelected" @change="toggleAll" /></th>
|
||||
<th>#</th><th>نام مشتری</th><th>شماره تلفن</th><th>کد زرین</th><th>گروه</th><th>عملیات</th>
|
||||
<th>#</th><th>نام مشتری</th><th>شماره تلفن</th><th>مانده</th><th>کد زرین</th><th>گروه</th><th>عملیات</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, idx) in pagedZarin" :key="c.code"
|
||||
:class="{ 'cs-table__row--done': zarinDone.has(c.code) }">
|
||||
:class="{
|
||||
'cs-table__row--done': zarinDone.has(c.code),
|
||||
'cs-table__row--no-phone': !rowHasPhone(c),
|
||||
}">
|
||||
<td>
|
||||
<a-checkbox
|
||||
:checked="selectedZarin.has(c.code)"
|
||||
:disabled="zarinDone.has(c.code)"
|
||||
:disabled="!rowHasPhone(c) || zarinDone.has(c.code)"
|
||||
@change="toggleZarin(c.code)"
|
||||
/>
|
||||
</td>
|
||||
<td class="cs-table__num">{{ (zarinPage - 1) * 20 + idx + 1 }}</td>
|
||||
<td class="cs-table__name">{{ c.name }}</td>
|
||||
<td class="cs-table__mobile">{{ c.tel || '—' }}</td>
|
||||
<td class="cs-table__mande">{{ formatMande(c) }}</td>
|
||||
<td class="cs-table__mobile">{{ c.code }}</td>
|
||||
<td class="cs-table__date">{{ c.group || '—' }}</td>
|
||||
<td class="cs-table__action">
|
||||
<span v-if="zarinDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
|
||||
<span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
|
||||
<a-button v-else type="primary" ghost size="small" @click="importOne(c)">
|
||||
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
|
||||
وارد کردن
|
||||
@@ -336,8 +371,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue/es'
|
||||
import { ref, computed, onMounted, reactive, watch, createVNode } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue/es'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import {
|
||||
@@ -345,6 +381,7 @@ import {
|
||||
syncPanelToZarin,
|
||||
importFromZarin,
|
||||
registerCustomerInZarin,
|
||||
linkZarinMatchingPhones,
|
||||
type CustomerSyncStatus,
|
||||
type PanelOnlyCustomer,
|
||||
type ZarinOnlyCustomer,
|
||||
@@ -352,6 +389,8 @@ import {
|
||||
} from '@/api/zarin'
|
||||
import { useCustomerImportStore } from '@/store/customerImportStore'
|
||||
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
|
||||
import { accountingZarinCustomerSyncIntro } from '@/utils/adminHelpCopy'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
|
||||
const importStore = useCustomerImportStore()
|
||||
|
||||
@@ -382,6 +421,40 @@ const zarinDone = reactive(new Set<number>())
|
||||
const selectedZarin = reactive(new Set<number>())
|
||||
const zarinSyncResult = ref<SyncPanelResult | null>(null)
|
||||
const showZarinErrors = ref(false)
|
||||
const linkingPhones = ref(false)
|
||||
|
||||
function rowHasPhone(c: ZarinOnlyCustomer) {
|
||||
if (c.has_phone === false) return false
|
||||
if (c.has_phone === true) return true
|
||||
const tel = (c.tel || '').replace(/\D/g, '')
|
||||
return tel.length >= 10
|
||||
}
|
||||
|
||||
const importableList = computed(() =>
|
||||
zarinOnlyList.value.filter(c => rowHasPhone(c) && !zarinDone.has(c.code)),
|
||||
)
|
||||
const importableCount = computed(() => importableList.value.length)
|
||||
const importableSelectedCount = computed(() =>
|
||||
[...selectedZarin].filter(code => {
|
||||
const c = zarinOnlyList.value.find(x => x.code === code)
|
||||
return c && rowHasPhone(c) && !zarinDone.has(code)
|
||||
}).length,
|
||||
)
|
||||
|
||||
function formatGold(v?: number | null) {
|
||||
if (v == null || Number.isNaN(Number(v))) return ''
|
||||
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
}
|
||||
function formatCash(v?: number | null) {
|
||||
if (v == null || Number.isNaN(Number(v))) return ''
|
||||
return Math.round(Number(v)).toLocaleString('fa-IR')
|
||||
}
|
||||
function formatMande(c: ZarinOnlyCustomer) {
|
||||
const g = formatGold(c.gold)
|
||||
const r = formatCash(c.cash)
|
||||
if (!g && !r) return '—'
|
||||
return [g ? `${g} طلا` : '', r ? `${r} ریال` : ''].filter(Boolean).join(' / ')
|
||||
}
|
||||
|
||||
// ─── computed ───
|
||||
const registeredPercent = computed(() => {
|
||||
@@ -396,12 +469,12 @@ const pagedZarin = computed(() => {
|
||||
return zarinOnlyList.value.slice(s, s + 20)
|
||||
})
|
||||
|
||||
const allPageSelected = computed(() =>
|
||||
pagedZarin.value.length > 0 &&
|
||||
pagedZarin.value.every(c => selectedZarin.has(c.code) || zarinDone.has(c.code))
|
||||
)
|
||||
const allPageSelected = computed(() => {
|
||||
const importable = pagedZarin.value.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
|
||||
return importable.length > 0 && importable.every(c => selectedZarin.has(c.code))
|
||||
})
|
||||
const someSelected = computed(() =>
|
||||
pagedZarin.value.some(c => selectedZarin.has(c.code)) && !allPageSelected.value
|
||||
pagedZarin.value.some(c => selectedZarin.has(c.code) && rowHasPhone(c)) && !allPageSelected.value
|
||||
)
|
||||
|
||||
// ─── load panel only (سریع — بدون API زرین) ───
|
||||
@@ -443,6 +516,13 @@ async function loadZarin(force = false) {
|
||||
if (status.value) {
|
||||
status.value.zarin_total = res.zarin_total ?? 0
|
||||
status.value.zarin_only_count = res.zarin_only_count ?? 0
|
||||
status.value.zarin_with_phone_count = res.zarin_with_phone_count ?? 0
|
||||
status.value.zarin_without_phone_count = res.zarin_without_phone_count ?? 0
|
||||
status.value.zarin_importable_count = res.zarin_importable_count ?? 0
|
||||
status.value.phone_match_count = res.phone_match_count ?? 0
|
||||
status.value.phone_conflict_count = res.phone_conflict_count ?? 0
|
||||
status.value.phone_matches = res.phone_matches ?? []
|
||||
status.value.phone_conflicts = res.phone_conflicts ?? []
|
||||
status.value.zarin_group_filter = res.zarin_group_filter ?? null
|
||||
status.value.include_zarin = res.include_zarin ?? false
|
||||
status.value.zarin_fetch_error = null
|
||||
@@ -542,31 +622,70 @@ async function syncAllPanel() {
|
||||
|
||||
// ─── تب ۲: عملیات ───
|
||||
function toggleZarin(code: number) {
|
||||
const row = zarinOnlyList.value.find(c => c.code === code)
|
||||
if (row && !rowHasPhone(row)) return
|
||||
if (selectedZarin.has(code)) selectedZarin.delete(code)
|
||||
else selectedZarin.add(code)
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const importable = pagedZarin.value.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
|
||||
if (allPageSelected.value) {
|
||||
pagedZarin.value.forEach(c => selectedZarin.delete(c.code))
|
||||
importable.forEach(c => selectedZarin.delete(c.code))
|
||||
} else {
|
||||
pagedZarin.value.forEach(c => { if (!zarinDone.has(c.code)) selectedZarin.add(c.code) })
|
||||
importable.forEach(c => selectedZarin.add(c.code))
|
||||
}
|
||||
}
|
||||
|
||||
function importReport(customers: ZarinOnlyCustomer[]) {
|
||||
const withPhone = customers.filter(rowHasPhone)
|
||||
const noPhone = customers.length - withPhone.length
|
||||
return [
|
||||
`${withPhone.length} مشتری با شماره وارد پنل میشوند.`,
|
||||
noPhone ? `${noPhone} بدون شماره وارد نمیشوند.` : '',
|
||||
'مانده زرین روی کیف خالی مینشیند. اگر اپ مانده داشته باشد برای تصمیم شما میماند.',
|
||||
'دو مانده با هم جمع نمیشوند.',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function confirmImport(customers: ZarinOnlyCustomer[], onOk: () => void) {
|
||||
const ready = customers.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
|
||||
if (!ready.length) {
|
||||
message.warning('مشتری دارای شماره برای وارد کردن نیست')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'وارد کردن به پنل؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: importReport(customers),
|
||||
okText: 'بله، وارد کن',
|
||||
cancelText: 'انصراف',
|
||||
centered: true,
|
||||
onOk,
|
||||
})
|
||||
}
|
||||
|
||||
async function doImport(customers: ZarinOnlyCustomer[]) {
|
||||
const ready = customers.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
|
||||
if (!ready.length) {
|
||||
message.warning('بدون شماره وارد پنل نمیشوند')
|
||||
return
|
||||
}
|
||||
syncingZarin.value = true
|
||||
zarinSyncResult.value = null
|
||||
showZarinErrors.value = false
|
||||
try {
|
||||
const res = await importFromZarin(customers)
|
||||
const res = await importFromZarin(ready)
|
||||
zarinSyncResult.value = res
|
||||
if (res.success) {
|
||||
message.success(`${res.success} مشتری وارد پنل شد`)
|
||||
customers.forEach(c => { if (!res.errors.find((e: any) => e.code === c.code)) zarinDone.add(c.code) })
|
||||
ready.forEach(c => { if (!res.errors.find((e: any) => e.code === c.code)) zarinDone.add(c.code) })
|
||||
if (status.value?.zarin_only_count != null) {
|
||||
status.value.zarin_only_count = Math.max(0, status.value.zarin_only_count - res.success)
|
||||
}
|
||||
if (status.value?.zarin_importable_count != null) {
|
||||
status.value.zarin_importable_count = Math.max(0, status.value.zarin_importable_count - res.success)
|
||||
}
|
||||
}
|
||||
if (res.failed) message.warning(`${res.failed} مشتری وارد نشد`)
|
||||
await loadPanel(panelPagination.value.page)
|
||||
@@ -575,18 +694,54 @@ async function doImport(customers: ZarinOnlyCustomer[]) {
|
||||
finally { syncingZarin.value = false }
|
||||
}
|
||||
|
||||
async function importOne(customer: ZarinOnlyCustomer) { await doImport([customer]) }
|
||||
async function importSelected() { await doImport(zarinOnlyList.value.filter(c => selectedZarin.has(c.code))) }
|
||||
async function importOne(customer: ZarinOnlyCustomer) {
|
||||
if (!rowHasPhone(customer)) {
|
||||
message.warning('بدون شماره از زرین وارد پنل نمیشود')
|
||||
return
|
||||
}
|
||||
confirmImport([customer], () => doImport([customer]))
|
||||
}
|
||||
async function importSelected() {
|
||||
const rows = zarinOnlyList.value.filter(c => selectedZarin.has(c.code))
|
||||
confirmImport(rows, () => doImport(rows))
|
||||
}
|
||||
|
||||
// «وارد کردن همه» با پروگرسبار سراسری و قابل مینیمایز (پسزمینهای)
|
||||
async function importAll() {
|
||||
const pending = zarinOnlyList.value.filter(c => !zarinDone.has(c.code))
|
||||
const pending = importableList.value
|
||||
if (!pending.length) return
|
||||
if (importStore.active) {
|
||||
importStore.expand()
|
||||
return
|
||||
}
|
||||
await importStore.start(pending, 'zarin')
|
||||
confirmImport(pending, () => importStore.start(pending, 'zarin'))
|
||||
}
|
||||
|
||||
async function confirmLinkPhones() {
|
||||
Modal.confirm({
|
||||
title: 'نشاندن کد زرین روی شماره یکسان؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: 'کد فقط روی مشتری پنل بدون کد مینشیند. اگر هر دو مانده داشته باشند برای تصمیم میماند.',
|
||||
okText: 'اعمال کد',
|
||||
cancelText: 'انصراف',
|
||||
centered: true,
|
||||
onOk: linkPhones,
|
||||
})
|
||||
}
|
||||
|
||||
async function linkPhones() {
|
||||
linkingPhones.value = true
|
||||
try {
|
||||
const res = await linkZarinMatchingPhones()
|
||||
if (res.linked) message.success(res.message)
|
||||
else message.info(res.message)
|
||||
if (res.conflicts) message.warning(`${res.conflicts} تداخل کد — دستی تصمیم بگیرید`)
|
||||
await loadZarin(true)
|
||||
await loadPanel(panelPagination.value.page)
|
||||
} catch {
|
||||
message.error('لینک شماره یکسان ناموفق بود')
|
||||
} finally {
|
||||
linkingPhones.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// با پیشرفت عملیات سراسری، ردیفهای وارد شده را همینجا هم تیک بزن
|
||||
@@ -634,12 +789,13 @@ onMounted(() => {
|
||||
@media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
|
||||
|
||||
&__title { font-size: 14px; font-weight: 700; }
|
||||
&__title-row { display: inline-flex; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ردیف اصلی: پنل ↔ زرین */
|
||||
.cs-stats-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -902,6 +1058,7 @@ onMounted(() => {
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&--done { opacity: 0.45; }
|
||||
&--no-phone { opacity: 0.55; filter: grayscale(0.35); }
|
||||
|
||||
&__check { flex-shrink: 0; }
|
||||
&__info { flex: 1; min-width: 0; }
|
||||
@@ -919,9 +1076,11 @@ onMounted(() => {
|
||||
|
||||
thead tr { background: var(--admin-bg-muted); border-bottom: 1px solid var(--admin-border); }
|
||||
th { padding: 10px 14px; text-align: right; font-size: 11px; font-weight: 600; color: var(--admin-text-secondary); white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid var(--admin-border); transition: background 0.15s; }
|
||||
tbody tr { border-bottom: 1px solid var(--admin-border); background: var(--admin-bg-surface); transition: background 0.15s; }
|
||||
tbody tr:nth-child(even) { background: var(--admin-bg-surface); }
|
||||
tbody tr:hover { background: var(--admin-bg-hover); }
|
||||
tbody tr.cs-table__row--done { opacity: 0.45; }
|
||||
tbody tr.cs-table__row--no-phone { opacity: 0.55; filter: grayscale(0.35); }
|
||||
td { padding: 10px 14px; color: var(--admin-text); }
|
||||
|
||||
&__num { color: var(--admin-text-secondary); font-size: 12px; width: 40px; }
|
||||
@@ -929,6 +1088,46 @@ onMounted(() => {
|
||||
&__mobile { font-family: monospace; font-size: 12px; color: var(--admin-text-muted); }
|
||||
&__date { font-size: 12px; color: var(--admin-text-secondary); }
|
||||
&__action { text-align: center; white-space: nowrap; }
|
||||
&__mande { font-size: 11px; color: var(--admin-text-secondary); white-space: nowrap; }
|
||||
}
|
||||
|
||||
.cs-skip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(148,163,184,0.18);
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-link-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.cs-link-banner {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.cs-link-banner__title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
.cs-link-banner__warn {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.cs-done-badge {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="tw-bulk">
|
||||
<div class="tw-bulk__row">
|
||||
<div class="tw-bulk__copy min-w-0 flex-1">
|
||||
<div class="tw-bulk__title-row">
|
||||
<p class="tw-bulk__title">همگامسازی مانده زرین</p>
|
||||
<AdminFieldHelp :text="accountingZarinBalanceSyncIntro" size="sm" />
|
||||
</div>
|
||||
<p class="tw-bulk__hint">
|
||||
کیف مشتریان لینکشده با مانده زرین؛ تداخلها برای تصمیم دستی میمانند.
|
||||
</p>
|
||||
</div>
|
||||
<div class="tw-bulk__actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="running && !overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(false)"
|
||||
>
|
||||
همگامسازی
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
:loading="running && overwriteMode"
|
||||
:disabled="running"
|
||||
@click="confirmAndStart(true)"
|
||||
>
|
||||
اعمال اجباری
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="running || resultVisible" class="tw-bulk__progress">
|
||||
<template v-if="running">
|
||||
<div class="tw-bulk__progress-row">
|
||||
<a-spin size="small" />
|
||||
<span>
|
||||
در حال همگامسازی…
|
||||
<template v-if="total > 0">{{ processed }} / {{ total }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-bulk__track">
|
||||
<div class="tw-bulk__track-fill" :style="{ width: percent + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="resultVisible">
|
||||
<div class="tw-bulk__progress-row">
|
||||
<span class="tw-bulk__ok">✓ {{ success }} موفق</span>
|
||||
<span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر</span>
|
||||
<span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} تصمیم</span>
|
||||
<span v-if="failed" class="tw-bulk__fail">✗ {{ failed }} ناموفق</span>
|
||||
<a-button
|
||||
v-if="errors.length || decisions.length"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="showDetails = !showDetails"
|
||||
>
|
||||
{{ showDetails ? 'پنهان' : 'جزئیات' }}
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="dismissResult">بستن</a-button>
|
||||
</div>
|
||||
<div v-if="showDetails && decisions.length" class="tw-bulk__decisions">
|
||||
<div class="tw-bulk__decisions-title">تداخل مانده — کدام را نگه داریم؟ (جمع نمیشود)</div>
|
||||
<div v-for="d in decisions" :key="d.id" class="tw-bulk__decision-row">
|
||||
<div>
|
||||
<b>{{ d.name }}</b>
|
||||
<template v-if="d.mobile"> ({{ d.mobile }})</template>
|
||||
— اپ: {{ formatGold(d.app_gold) }} طلا / {{ formatCash(d.app_cash) }} ریال
|
||||
· زرین: {{ formatGold(d.tahesab_gold) }} طلا / {{ formatCash(d.tahesab_cash) }} ریال
|
||||
</div>
|
||||
<div class="tw-bulk__decision-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="decidingId === d.id && decidingMode === 'overwrite'"
|
||||
:disabled="!!decidingId"
|
||||
@click="decideOne(d.id, 'overwrite')"
|
||||
>
|
||||
اعمال زرین
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="decidingId === d.id && decidingMode === 'keep'"
|
||||
:disabled="!!decidingId"
|
||||
@click="decideOne(d.id, 'keep')"
|
||||
>
|
||||
حفظ اپ
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showDetails && errors.length" class="tw-bulk__errors">
|
||||
<div v-for="e in errors" :key="e.id">
|
||||
<b>{{ e.name }}</b>
|
||||
<template v-if="e.mobile"> ({{ e.mobile }})</template>
|
||||
— {{ e.error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, createVNode, ref } from 'vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
syncAllWalletsFromZarinChunk,
|
||||
syncCustomerWalletFromZarin,
|
||||
type ZarinBulkWalletSyncChunk,
|
||||
} from '@/api/zarin'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
import { accountingZarinBalanceSyncIntro } from '@/utils/adminHelpCopy'
|
||||
|
||||
const CHUNK_LIMIT = 20
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const running = ref(false)
|
||||
const overwriteMode = ref(false)
|
||||
const resultVisible = ref(false)
|
||||
const showDetails = ref(false)
|
||||
|
||||
const total = ref(0)
|
||||
const processed = ref(0)
|
||||
const success = ref(0)
|
||||
const changed = ref(0)
|
||||
const needsDecision = ref(0)
|
||||
const failed = ref(0)
|
||||
const errors = ref<ZarinBulkWalletSyncChunk['errors']>([])
|
||||
const decisions = ref<ZarinBulkWalletSyncChunk['decisions']>([])
|
||||
const decidingId = ref<number | null>(null)
|
||||
const decidingMode = ref<'overwrite' | 'keep' | null>(null)
|
||||
|
||||
const percent = computed(() =>
|
||||
total.value > 0 ? Math.min(100, Math.round((processed.value / total.value) * 100)) : 0,
|
||||
)
|
||||
|
||||
function formatGold(v?: number) {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
}
|
||||
|
||||
function formatCash(v?: number) {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return Math.round(Number(v)).toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
function dismissResult() {
|
||||
resultVisible.value = false
|
||||
showDetails.value = false
|
||||
}
|
||||
|
||||
async function decideOne(customerId: number, mode: 'overwrite' | 'keep') {
|
||||
decidingId.value = customerId
|
||||
decidingMode.value = mode
|
||||
try {
|
||||
const res = await syncCustomerWalletFromZarin(customerId, {
|
||||
overwrite: mode === 'overwrite',
|
||||
keep: mode === 'keep',
|
||||
ask_if_conflict: false,
|
||||
})
|
||||
decisions.value = decisions.value.filter(d => d.id !== customerId)
|
||||
needsDecision.value = Math.max(0, needsDecision.value - 1)
|
||||
if (mode === 'keep') message.info(res.message || 'موجودی اپ حفظ شد')
|
||||
else message.success(res.message || 'مانده زرین اعمال شد')
|
||||
} catch {
|
||||
message.error('اعمال تصمیم ناموفق بود')
|
||||
} finally {
|
||||
decidingId.value = null
|
||||
decidingMode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAndStart(overwrite: boolean) {
|
||||
if (running.value) return
|
||||
Modal.confirm({
|
||||
title: overwrite
|
||||
? 'اعمال اجباری مانده زرین برای همه؟'
|
||||
: 'همگامسازی ماندهٔ همهٔ مشتریان؟',
|
||||
icon: createVNode(ExclamationCircleOutlined),
|
||||
content: overwrite
|
||||
? 'موجودی کیف همهٔ مشتریان دارای کد زرین با ماندهٔ زرین جایگزین میشود — حتی اگر در اپ مانده داشته باشند.'
|
||||
: 'برای مشتریانی که کیف خالی دارند مانده زرین مینشیند. اگر اپ مانده داشته باشد و فرق کند، در لیست «نیازمند تصمیم» میآید.',
|
||||
okText: overwrite ? 'بله، برای همه اعمال کن' : 'شروع همگامسازی',
|
||||
okType: overwrite ? 'danger' : 'primary',
|
||||
cancelText: 'انصراف',
|
||||
centered: true,
|
||||
onOk: () => start(overwrite),
|
||||
})
|
||||
}
|
||||
|
||||
async function start(overwrite: boolean) {
|
||||
running.value = true
|
||||
overwriteMode.value = overwrite
|
||||
resultVisible.value = false
|
||||
showDetails.value = false
|
||||
total.value = 0
|
||||
processed.value = 0
|
||||
success.value = 0
|
||||
changed.value = 0
|
||||
needsDecision.value = 0
|
||||
failed.value = 0
|
||||
errors.value = []
|
||||
decisions.value = []
|
||||
|
||||
let offset = 0
|
||||
let done = false
|
||||
|
||||
try {
|
||||
while (!done) {
|
||||
const res = await syncAllWalletsFromZarinChunk({
|
||||
offset,
|
||||
limit: CHUNK_LIMIT,
|
||||
overwrite,
|
||||
})
|
||||
|
||||
total.value = res.total
|
||||
success.value += res.success
|
||||
changed.value += res.changed
|
||||
needsDecision.value += res.needs_decision ?? 0
|
||||
failed.value += res.failed
|
||||
if (res.errors?.length) errors.value = [...errors.value, ...res.errors]
|
||||
if (res.decisions?.length) decisions.value = [...decisions.value, ...res.decisions]
|
||||
|
||||
processed.value = Math.min(res.next_offset, res.total)
|
||||
offset = res.next_offset
|
||||
done = res.done || res.processed === 0 || res.total === 0
|
||||
|
||||
if (!done) await sleep(150)
|
||||
}
|
||||
|
||||
resultVisible.value = true
|
||||
if (failed.value === 0 && needsDecision.value === 0) {
|
||||
message.success(
|
||||
total.value === 0
|
||||
? 'مشتری دارای کد زرین یافت نشد'
|
||||
: `همگامسازی ${success.value} مشتری انجام شد`,
|
||||
)
|
||||
} else if (success.value > 0 || needsDecision.value > 0) {
|
||||
message.warning(
|
||||
`${success.value} موفق` +
|
||||
(needsDecision.value ? ` — ${needsDecision.value} نیازمند تصمیم` : '') +
|
||||
(failed.value ? ` — ${failed.value} ناموفق` : ''),
|
||||
)
|
||||
} else {
|
||||
message.error('همگامسازی ناموفق بود')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('خطا در ارتباط با سرور')
|
||||
resultVisible.value = processed.value > 0 || success.value > 0 || failed.value > 0
|
||||
} finally {
|
||||
running.value = false
|
||||
overwriteMode.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tw-bulk {
|
||||
@apply rounded-lg border border-base p-3;
|
||||
background: var(--admin-bg-surface, var(--admin-bg-elevated));
|
||||
}
|
||||
|
||||
/* موبایل: ستون — دکمهها زیر توضیح | دسکتاپ: ردیف */
|
||||
.tw-bulk__row {
|
||||
@apply flex flex-col items-stretch gap-2.5;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__row {
|
||||
@apply flex-row items-center gap-3;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__title {
|
||||
@apply m-0 text-sm font-600;
|
||||
color: var(--admin-text, inherit);
|
||||
}
|
||||
|
||||
.tw-bulk__title-row {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.tw-bulk__hint {
|
||||
@apply m-0 mt-0.5 text-xs leading-5;
|
||||
color: var(--admin-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.tw-bulk__actions {
|
||||
@apply flex flex-wrap items-center gap-1.5;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tw-bulk__actions {
|
||||
@apply shrink-0;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-bulk__progress {
|
||||
@apply mt-2 rounded-md border border-base p-2 text-xs;
|
||||
background: var(--admin-bg-muted, transparent);
|
||||
color: var(--admin-text, inherit);
|
||||
}
|
||||
|
||||
.tw-bulk__progress-row {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.tw-bulk__track {
|
||||
@apply mt-1.5 h-1 overflow-hidden rounded-full;
|
||||
background: var(--admin-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.tw-bulk__track-fill {
|
||||
@apply h-full rounded-full transition-all duration-300;
|
||||
background: var(--admin-primary, #d4af37);
|
||||
}
|
||||
|
||||
.tw-bulk__ok {
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.tw-bulk__changed {
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.tw-bulk__decision {
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tw-bulk__fail {
|
||||
@apply rounded-full px-2 py-0.5 font-600;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tw-bulk__decisions {
|
||||
@apply mt-1.5 max-h-32 overflow-y-auto rounded-md p-2 text-xs leading-6;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.tw-bulk__decision-row {
|
||||
@apply mb-2 flex flex-col gap-1.5 border-b pb-2 last:mb-0 last:border-0 last:pb-0;
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
.tw-bulk__decision-actions {
|
||||
@apply flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.tw-bulk__errors {
|
||||
@apply mt-1.5 max-h-24 overflow-y-auto rounded-md p-2 text-xs leading-6;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
|
||||
<BasicShowContext title="اسناد پرگار">
|
||||
<BasicShowContext title="اسناد پرگار" :help="accountingPargarDocsIntro">
|
||||
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
|
||||
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
|
||||
<template #action="{ record }">
|
||||
@@ -137,6 +137,7 @@ import {
|
||||
pargarTypeLabel
|
||||
} from '@/pages/menuPages/accounting/utils/pargarDisplay'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import { accountingPargarDocsIntro } from '@/utils/adminHelpCopy'
|
||||
|
||||
const [submit, reload] = useBaseTable()
|
||||
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
|
||||
<BasicShowContext title="حسابداری">
|
||||
<BasicShowContext title="حسابداری" :help="accountingReportsPageIntro">
|
||||
<div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
|
||||
<BaseTable v-bind="tblProps" @register="submit" />
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@ import AccountingMobileCardList from '@/pages/menuPages/accounting/components/Ac
|
||||
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
|
||||
import type { TableInput } from '@core/type/baseTable'
|
||||
import { isMobile } from '@core/utils/is'
|
||||
import { accountingReportsPageIntro } from '@/utils/adminHelpCopy'
|
||||
|
||||
const [submit, reload] = useBaseTable()
|
||||
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
|
||||
<BasicShowContext :title="customerId ? 'اسناد تهحساب این مشتری' : 'اسناد تهحساب'">
|
||||
<BasicShowContext
|
||||
:title="customerId ? 'اسناد تهحساب این مشتری' : 'اسناد تهحساب'"
|
||||
:help="accountingTahesabDocsIntro"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button
|
||||
v-if="(pending?.total ?? 0) > 0"
|
||||
@@ -213,6 +216,7 @@ import {
|
||||
rialAmountLabel
|
||||
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import { accountingTahesabDocsIntro } from '@/utils/adminHelpCopy'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: number
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
|
||||
<BasicShowContext :title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'">
|
||||
<BasicShowContext
|
||||
:title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'"
|
||||
:help="accountingZarinDocsIntro"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button
|
||||
v-if="(pending?.total ?? 0) > 0"
|
||||
@@ -212,6 +215,7 @@ import {
|
||||
rialAmountLabel
|
||||
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
|
||||
import { toJalali } from '@core/utils/useDataTime'
|
||||
import { accountingZarinDocsIntro } from '@/utils/adminHelpCopy'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: number
|
||||
|
||||
Reference in New Issue
Block a user