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:
2026-08-30 21:18:26 +03:30
co-authored by Cursor
parent 04cc03fe25
commit 1c4b2d4435
60 changed files with 2464 additions and 552 deletions
@@ -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
+12 -3
View File
@@ -3,25 +3,31 @@
<BasicMainCard>
<div
class="flex h-full w-full min-w-0 max-w-full flex-col overflow-x-hidden"
:class="activeTab === 'customer-sync' || activeTab === 'pargar-customer-sync' || activeTab === 'zarin-customer-sync' ? 'overflow-y-auto' : 'overflow-hidden'"
:class="activeTab === 'customer-sync' || activeTab === 'pargar-customer-sync' || activeTab === 'zarin-customer-sync' || activeTab === 'tahesab-wallet-sync' || activeTab === 'zarin-wallet-sync' || activeTab === 'pargar-wallet-sync' ? 'overflow-y-auto' : 'overflow-hidden'"
>
<PargarStuckOutboundAlert v-if="pargarEnabled" class="mb-3 shrink-0" />
<ATabs v-model:activeKey="activeTab" class="mb-3 shrink-0">
<ATabPane key="reports" tab="گزارشات" />
<ATabPane v-if="tahesabEnabled" key="tahesab" tab="اسناد ته‌حساب" />
<ATabPane v-if="tahesabEnabled" key="customer-sync" tab="همگام سازی مشتریان در ته‌حساب" />
<ATabPane v-if="tahesabEnabled" key="tahesab-wallet-sync" tab="مانده کیف ته‌حساب" />
<ATabPane v-if="zarinEnabled" key="zarin" tab="اسناد زرین" />
<ATabPane v-if="zarinEnabled" key="zarin-customer-sync" tab="همگام سازی مشتریان در زرین" />
<ATabPane v-if="zarinEnabled" key="zarin-wallet-sync" tab="مانده کیف زرین" />
<ATabPane v-if="pargarEnabled" key="pargar" tab="اسناد پرگار" />
<ATabPane v-if="pargarEnabled" key="pargar-customer-sync" tab="همگام سازی مشتریان در پرگار" />
<ATabPane v-if="pargarEnabled" key="pargar-wallet-sync" tab="مانده کیف پرگار" />
</ATabs>
<tbl v-if="activeTab === 'reports'" ref="table" class="min-h-0 flex-1" />
<tahesabTbl v-else-if="tahesabEnabled && activeTab === 'tahesab'" ref="tahesabTable" class="min-h-0 flex-1" />
<customerSync v-else-if="tahesabEnabled && activeTab === 'customer-sync'" class="shrink-0" />
<tahesabWalletSync v-else-if="tahesabEnabled && activeTab === 'tahesab-wallet-sync'" class="min-h-0 flex-1" />
<zarinTbl v-else-if="zarinEnabled && activeTab === 'zarin'" ref="zarinTable" class="min-h-0 flex-1" />
<zarinCustomerSync v-else-if="zarinEnabled && activeTab === 'zarin-customer-sync'" class="shrink-0" />
<zarinWalletSync v-else-if="zarinEnabled && activeTab === 'zarin-wallet-sync'" class="shrink-0" />
<pargarTbl v-else-if="pargarEnabled && activeTab === 'pargar'" ref="pargarTable" class="min-h-0 flex-1" />
<pargarCustomerSync v-else-if="pargarEnabled && activeTab === 'pargar-customer-sync'" class="shrink-0" />
<pargarWalletSync v-else-if="pargarEnabled && activeTab === 'pargar-wallet-sync'" class="shrink-0" />
</div>
</BasicMainCard>
<BasicModal
@@ -148,14 +154,17 @@
// import BasicInfoModal from '@/components/BasicInfoModal'
import { inputLenRules, inputRules, inputRulesNumber } from '@/utils/inputRules'
import { useStaticApiListsStore } from '@/store/staticApisStore'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import tbl from './components/table.vue'
import tahesabTbl from './components/tahesabTable.vue'
import customerSync from './components/TahesabCustomerSync.vue'
import tahesabWalletSync from './components/TahesabWalletBulkSync.vue'
import zarinTbl from './components/zarinTable.vue'
import zarinCustomerSync from './components/ZarinCustomerSync.vue'
import zarinWalletSync from './components/ZarinWalletBulkSync.vue'
import pargarTbl from './components/pargarTable.vue'
import pargarCustomerSync from './components/PargarCustomerSync.vue'
import pargarWalletSync from './components/PargarWalletBulkSync.vue'
import PargarStuckOutboundAlert from './components/PargarStuckOutboundAlert.vue'
import type { FormInstance } from 'ant-design-vue'
import BasicInfoModal from '@/components/BasicInfoModal/index.vue'
@@ -179,7 +188,7 @@ import { initAccountingSettingsFromApi, tahesabEnabledSetting, zarinEnabledSetti
//loading
const loading = ref<boolean>(false)
const submitError = ref('')
const activeTab = ref<'reports' | 'tahesab' | 'customer-sync' | 'zarin' | 'zarin-customer-sync' | 'pargar' | 'pargar-customer-sync'>('reports')
const activeTab = ref<'reports' | 'tahesab' | 'customer-sync' | 'tahesab-wallet-sync' | 'zarin' | 'zarin-customer-sync' | 'zarin-wallet-sync' | 'pargar' | 'pargar-customer-sync' | 'pargar-wallet-sync'>('reports')
const tahesabEnabled = tahesabEnabledSetting
const zarinEnabled = zarinEnabledSetting
const pargarEnabled = pargarEnabledSetting