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
-16
View File
@@ -11,7 +11,6 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
@@ -25,42 +24,27 @@ declare module '@vue/runtime-core' {
AdminHelpTooltipContent: typeof import('./src/components/AdminHelpTooltipContent.vue')['default']
AdminLabelWithHelp: typeof import('./src/components/AdminLabelWithHelp.vue')['default']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUpload: typeof import('ant-design-vue/es')['Upload']
BaseButton: typeof import('./src/core/components/BaseButton/index.ts')['default']
BaseCard: typeof import('./src/core/components/BaseCard/index.ts')['default']
BaseCollapse: typeof import('./src/components/BaseCollapse.vue')['default']
+97 -1
View File
@@ -89,6 +89,19 @@ export interface TahesabOnlyCustomer {
tel: string
gid: number | null
group: string
has_phone?: boolean
gold?: number | null
cash?: number | null
}
export interface AccountingPhoneMatch {
code: number
name: string
tel: string
panel_id: number
panel_name: string
panel_code?: string | null
error?: string
}
export interface CustomerSyncStatus {
@@ -97,6 +110,13 @@ export interface CustomerSyncStatus {
panel_only: number
tahesab_total: number
tahesab_only_count: number
tahesab_with_phone_count?: number | null
tahesab_without_phone_count?: number | null
tahesab_importable_count?: number | null
phone_match_count?: number | null
phone_conflict_count?: number | null
phone_matches?: AccountingPhoneMatch[]
phone_conflicts?: AccountingPhoneMatch[]
panel_only_list: {
data: PanelOnlyCustomer[]
current_page: number
@@ -115,6 +135,7 @@ export const getCustomerSyncStatus = async (params?: {
search?: string
page?: number
tahesab_page?: number
include_tahesab?: number | boolean
}) => await useAxios.get<CustomerSyncStatus>('/admin/tahesab/customer-sync/status', { params })
export interface SyncPanelResult {
@@ -132,6 +153,33 @@ export const importFromTahesab = async (customers: TahesabOnlyCustomer[]) =>
customers,
})
export const linkTahesabMatchingPhones = async () =>
await useAxios.post<{
ok: boolean
linked: number
conflicts: number
message: string
linked_rows: AccountingPhoneMatch[]
conflict_rows: AccountingPhoneMatch[]
}>('/admin/tahesab/customer-sync/link-by-phone')
export const fetchTahesabMandeByCodes = async (codes: number[]) =>
await useAxios.post<{ ok: boolean; mandes: Record<string, { gold: number; cash: number }> }>(
'/admin/tahesab/mande-by-codes',
{ codes },
)
export interface TahesabPreviewDoc {
id: number
title?: string
gold?: number
cash?: number
amount?: number
type?: string
date?: string
created_at?: string | null
}
export interface TahesabWalletSyncResult {
ok: boolean
needs_decision?: boolean
@@ -146,6 +194,10 @@ export interface TahesabWalletSyncResult {
app_cash?: number
tahesab_gold?: number
tahesab_cash?: number
zarin_gold?: number
zarin_cash?: number
wallet_docs?: TahesabPreviewDoc[]
tahesab_docs?: TahesabPreviewDoc[]
message: string
}
}
@@ -153,7 +205,7 @@ export interface TahesabWalletSyncResult {
/** همگام‌سازی مانده یک مشتری با ته‌حساب */
export const syncCustomerWalletFromTahesab = async (
customerId: number,
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean }
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean; always_ask?: boolean }
) =>
await useAxios.post<TahesabWalletSyncResult>(
`/admin/customer/${customerId}/tahesab/sync-wallet`,
@@ -161,9 +213,16 @@ export const syncCustomerWalletFromTahesab = async (
overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true,
always_ask: opts?.always_ask ?? false,
}
)
export const previewCustomerWalletFromTahesab = async (customerId: number, code?: string | number) =>
await useAxios.post<TahesabWalletSyncResult>(
`/admin/customer/${customerId}/tahesab/preview-wallet`,
{ code },
)
export interface TahesabBulkWalletSyncChunk {
ok: boolean
message: string
@@ -200,3 +259,40 @@ export const syncAllWalletsFromTahesabChunk = async (params?: {
limit: params?.limit ?? 20,
overwrite: params?.overwrite ?? false,
})
export interface TahesabWalletLinkedRow {
id: number
name: string
mobile: string
moshtari_code: number | null
app_gold: number
app_cash: number
tahesab_gold: number | null
tahesab_cash: number | null
gold_delta?: number | null
rial_delta?: number | null
status: 'same' | 'conflict' | 'ready' | 'missing'
message: string
}
export interface TahesabWalletLinkedList {
ok: boolean
total: number
page: number
per_page: number
last_page: number
rows: TahesabWalletLinkedRow[]
}
export const getTahesabWalletLinkedList = async (params?: {
search?: string
page?: number
per_page?: number
}) =>
await useAxios.get<TahesabWalletLinkedList>('/admin/tahesab/wallet-sync/list', {
params: {
search: params?.search || undefined,
page: params?.page ?? 1,
per_page: params?.per_page ?? 20,
},
})
+106
View File
@@ -89,6 +89,19 @@ export interface ZarinOnlyCustomer {
tel: string
gid: number | null
group: string
has_phone?: boolean
gold?: number | null
cash?: number | null
}
export interface AccountingPhoneMatch {
code: number
name: string
tel: string
panel_id: number
panel_name: string
panel_code?: string | null
error?: string
}
export interface CustomerSyncStatus {
@@ -97,6 +110,13 @@ export interface CustomerSyncStatus {
panel_only: number
zarin_total: number | null
zarin_only_count: number | null
zarin_with_phone_count?: number | null
zarin_without_phone_count?: number | null
zarin_importable_count?: number | null
phone_match_count?: number | null
phone_conflict_count?: number | null
phone_matches?: AccountingPhoneMatch[]
phone_conflicts?: AccountingPhoneMatch[]
/** اگر دریافت لیست شکست بخورد */
zarin_fetch_error?: string | null
zarin_http_status?: number | null
@@ -148,3 +168,89 @@ export const importFromZarin = async (customers: ZarinOnlyCustomer[]) =>
await useAxios.post<SyncPanelResult>('/admin/zarin/customer-sync/import-from-zarin', {
customers,
})
export const linkZarinMatchingPhones = async () =>
await useAxios.post<{
ok: boolean
linked: number
conflicts: number
message: string
linked_rows: AccountingPhoneMatch[]
conflict_rows: AccountingPhoneMatch[]
}>('/admin/zarin/customer-sync/link-by-phone')
export interface ZarinWalletSyncResult {
ok: boolean
needs_decision?: boolean
message: string
result?: {
applied: boolean
needs_decision?: boolean
skipped?: boolean
gold_delta?: number
rial_delta?: number
app_gold?: number
app_cash?: number
zarin_gold?: number
zarin_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}
}
export const syncCustomerWalletFromZarin = async (
customerId: number,
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean; always_ask?: boolean }
) =>
await useAxios.post<ZarinWalletSyncResult>(
`/admin/customer/${customerId}/zarin/sync-wallet`,
{
overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true,
always_ask: opts?.always_ask ?? false,
}
)
export const previewCustomerWalletFromZarin = async (customerId: number, code?: string | number) =>
await useAxios.post<ZarinWalletSyncResult>(
`/admin/customer/${customerId}/zarin/preview-wallet`,
{ code },
)
export interface ZarinBulkWalletSyncChunk {
ok: boolean
message: string
total: number
offset: number
processed: number
next_offset: number
done: boolean
success: number
changed: number
needs_decision: number
failed: number
errors: { id: number; name: string; mobile?: string; error: string }[]
decisions: {
id: number
name: string
mobile?: string
app_gold?: number
app_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}[]
}
export const syncAllWalletsFromZarinChunk = async (params?: {
offset?: number
limit?: number
overwrite?: boolean
}) =>
await useAxios.post<ZarinBulkWalletSyncChunk>('/admin/zarin/wallet-sync/all', {
offset: params?.offset ?? 0,
limit: params?.limit ?? 20,
overwrite: params?.overwrite ?? false,
})
+10 -2
View File
@@ -1,7 +1,10 @@
<template>
<div class="relative flex h-full min-h-0 min-w-0 w-full max-w-full flex-col overflow-hidden py-2 pr-3">
<div class="flex shrink-0 min-w-0 items-center justify-between gap-2">
<div class="truncate font-bold text-[var(--admin-text)]">{{ props.title }}</div>
<div class="flex min-w-0 items-center gap-1.5">
<div class="truncate font-bold text-[var(--admin-text)]">{{ props.title }}</div>
<AdminFieldHelp v-if="props.help" :text="props.help" size="sm" />
</div>
<slot name="extra" />
</div>
<div class="mt-2 min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-1">
@@ -11,8 +14,13 @@
</template>
<script setup lang="ts">
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
interface Prop {
title: string
help?: string
}
const props = defineProps<Prop>()
const props = withDefaults(defineProps<Prop>(), {
help: '',
})
</script>
+149 -25
View File
@@ -14,6 +14,9 @@
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL';
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL';
const CONFIG_CACHE_KEY = 'APP_CONFIG_CACHE';
const CONFIG_IDB_NAME = 'abshode_admin_cfg';
const CONFIG_IDB_STORE = 'kv';
const CONFIG_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const VERSION_STORAGE_KEY = 'version';
const VERSION_UPDATE_LOCK_KEY = 'version_update_lock';
const VERSION_LAST_RELOAD_KEY = 'version_last_reload';
@@ -130,13 +133,13 @@
return readUserVersion(response.user);
}
function readCachedAppConfig() {
function readCachedAppConfigPayload() {
try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (!payload || typeof payload !== 'object' || !payload.config) return null;
return payload.config;
return payload;
} catch (error) {
console.warn('Failed to read cached get_user:', error);
localStorage.removeItem(CONFIG_CACHE_KEY);
@@ -144,18 +147,125 @@
}
}
function writeCachedAppConfig(config) {
try {
localStorage.setItem(
CONFIG_CACHE_KEY,
JSON.stringify({
cached_at: Date.now(),
config: config
})
);
} catch (error) {
console.warn('Failed to write get_user cache:', error);
function readCachedAppConfig() {
const payload = readCachedAppConfigPayload();
return payload && payload.config ? payload.config : null;
}
function isFreshGetUserCache(payload, remoteVersion, domain) {
if (!payload || !payload.config) return false;
if (isServerUnreachable(payload.config)) return false;
const cachedDomain = String(payload.domain || '').trim();
if (cachedDomain && cachedDomain !== domain) return false;
const age = Date.now() - Number(payload.cached_at || 0);
if (!payload.cached_at || age < 0 || age > CONFIG_CACHE_MAX_AGE_MS) return false;
const cachedVersion = normalizeVersion(payload.version);
if (remoteVersion) {
return cachedVersion === remoteVersion;
}
return Boolean(cachedVersion);
}
function openConfigIdb() {
return new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
const req = window.indexedDB.open(CONFIG_IDB_NAME, 1);
req.onupgradeneeded = function () {
if (!req.result.objectStoreNames.contains(CONFIG_IDB_STORE)) {
req.result.createObjectStore(CONFIG_IDB_STORE);
}
};
req.onsuccess = function () {
resolve(req.result);
};
req.onerror = function () {
reject(req.error);
};
});
}
function writeCachedAppConfigToIdb(payload) {
openConfigIdb()
.then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(CONFIG_IDB_STORE, 'readwrite');
tx.oncomplete = function () {
resolve();
};
tx.onerror = function () {
reject(tx.error);
};
tx.objectStore(CONFIG_IDB_STORE).put(payload, CONFIG_CACHE_KEY);
});
})
.catch(function (error) {
console.warn('Failed to write get_user IndexedDB cache:', error);
});
}
function readCachedAppConfigFromIdb() {
return openConfigIdb()
.then(function (db) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(CONFIG_IDB_STORE, 'readonly');
const req = tx.objectStore(CONFIG_IDB_STORE).get(CONFIG_CACHE_KEY);
req.onsuccess = function () {
const value = req.result;
if (!value || typeof value !== 'object' || !value.config) {
resolve(null);
return;
}
resolve(value);
};
req.onerror = function () {
reject(req.error);
};
});
})
.catch(function () {
return null;
});
}
function clearCachedAppConfigIdb() {
openConfigIdb()
.then(function (db) {
const tx = db.transaction(CONFIG_IDB_STORE, 'readwrite');
tx.objectStore(CONFIG_IDB_STORE).delete(CONFIG_CACHE_KEY);
})
.catch(function () {
/* ignore */
});
}
function writeCachedAppConfig(config, version) {
const payload = {
cached_at: Date.now(),
version: normalizeVersion(version || getStoredAppVersion()),
domain: resolveTenantDomain(),
config: config
};
try {
localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(payload));
clearCachedAppConfigIdb();
} catch (error) {
console.warn('get_user too large for localStorage; using IndexedDB', error);
try {
localStorage.removeItem(CONFIG_CACHE_KEY);
} catch {
/* ignore */
}
writeCachedAppConfigToIdb(payload);
}
}
async function resolveCachedPayload() {
const fromLs = readCachedAppConfigPayload();
if (fromLs) return fromLs;
return await readCachedAppConfigFromIdb();
}
function getStoredAppVersion() {
@@ -238,6 +348,7 @@
localStorage.removeItem(CONFIG_CACHE_KEY);
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY);
localStorage.removeItem(VERSION_SUCCESS_URL_KEY);
clearCachedAppConfigIdb();
restoreAuthToken(token);
}
@@ -311,7 +422,7 @@
return null;
}
async function fetchConfig(rounds) {
async function fetchConfig(rounds, version) {
rounds = rounds || CONFIG_RETRY_ROUNDS;
const urls = sortUrls(getConfigUrls(), CONFIG_SUCCESS_URL_KEY);
@@ -328,7 +439,7 @@
}
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url);
writeCachedAppConfig(config);
writeCachedAppConfig(config, version);
return { config: config, url: url };
} catch (error) {
clearStaleSuccessUrl(CONFIG_SUCCESS_URL_KEY, url);
@@ -413,15 +524,15 @@
startVersionUpdateLock(serverVersion);
try {
const { config } = await fetchConfig();
const { config } = await fetchConfig(undefined, serverVersion);
if (isServerUnreachable(config)) {
writeCachedAppConfig(config);
writeCachedAppConfig(config, serverVersion);
showServerUnavailable();
cancelVersionUpdateLock();
return true;
}
clearBootstrapCachesKeepToken();
writeCachedAppConfig(config);
writeCachedAppConfig(config, serverVersion);
applyConfig(config);
finishVersionUpdateBeforeReload(serverVersion);
console.log('App version changed — hard refresh', serverVersion);
@@ -441,8 +552,8 @@
let needsFreshForVersion = false;
try {
// کش قدیمی را فقط اگر خودش قفل باشد اعمال/قطع کن؛ وگرنه تا get_user تازه صبر کن
const cachedConfig = readCachedAppConfig();
const cachedPayload = await resolveCachedPayload();
const cachedConfig = cachedPayload && cachedPayload.config ? cachedPayload.config : null;
if (cachedConfig && isServerUnreachable(cachedConfig)) {
showServerUnavailable();
return;
@@ -461,9 +572,23 @@
}
}
const { config, url } = await fetchConfig();
const domain = resolveTenantDomain();
if (
!needsFreshForVersion &&
isFreshGetUserCache(cachedPayload, remoteVersion, domain)
) {
applyConfig(cachedPayload.config);
if (remoteVersion) {
writeStoredAppVersion(remoteVersion);
}
console.log('Config loaded from cache; get_user skipped');
finishConfigLoad(cachedPayload.config);
return;
}
const { config, url } = await fetchConfig(undefined, remoteVersion);
applyConfig(config);
writeCachedAppConfig(config);
writeCachedAppConfig(config, remoteVersion);
if (remoteVersion) {
writeStoredAppVersion(remoteVersion);
}
@@ -477,13 +602,13 @@
console.warn('Config loader: Could not fetch config from any endpoint');
console.warn('Error details:', error && error.message ? error.message : error);
// اگر نسخه عوض شده (قفل/آنلاک)، هرگز با کش قدیمی reachable ادمین را بالا نیاور
if (versionChanged || needsFreshForVersion) {
showServerUnavailable();
return;
}
const cachedConfig = readCachedAppConfig();
const cachedPayload = await resolveCachedPayload();
const cachedConfig = cachedPayload && cachedPayload.config ? cachedPayload.config : null;
if (cachedConfig) {
try {
applyConfig(cachedConfig);
@@ -519,7 +644,6 @@
showServerUnavailable();
return;
}
// بدون تأیید تازه از get_user، ادمین را با کش بالا نیاور (دور زدن قفل)
showServerUnavailable();
}
}, CONFIG_TIMEOUT);
@@ -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
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="اعلان‌های اپ">
<BasicShowContext title="اعلان‌های اپ" :help="appNotificationsPageIntro">
<template #extra>
<div class="flex items-center gap-2 flex-wrap">
<a-radio-group
@@ -17,9 +17,6 @@
{{ tab.label }}
</a-radio-button>
</a-radio-group>
<AdminFieldHelp
text="اعلان ادمین را دستی می‌فرستید؛ اعلان سیستمی خودکار برای خرید/فروش/واریز/برداشت ساخته می‌شود. از تب‌ها برای جدا کردن لیست استفاده کنید."
/>
<BaseButton type="primary" size="small" @click="emits('add')">
<span>افزودن اعلان</span>
<BaseIcon icon="vuesax-linear:add" size="16" />
@@ -89,7 +86,7 @@ import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/appNotificationsColumns'
import { getAppNotificationsTable } from '@/api/appNotifications'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { appNotificationsPageIntro } from '@/utils/adminHelpCopy'
import {
APP_NOTIFICATION_SOURCE_TABS,
type AppNotificationSourceFilter,
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="اتوماسیون">
<BasicShowContext title="اتوماسیون" :help="automationsPageIntro">
<template #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')">
افزودن
@@ -103,6 +103,7 @@ import { cols } from '../hooks/automationsColumns'
import { getAutomationsTable } from '@/api/automations'
import type { Automation } from '@/model/automations'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import { automationsPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
+4 -4
View File
@@ -122,7 +122,7 @@
</a-collapse-panel>
</a-collapse>
<SettingsProfileBanners v-if="showSettingsProfileBanners" />
<SettingsProfileBanners v-if="showThemeBanners" />
</div>
</div>
</template>
@@ -133,7 +133,7 @@ import SettingsProfileBanners from './components/SettingsProfileBanners.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import { hasAppUiConfig } from '@/utils/config'
import { isModularUiVariant } from '@/utils/config'
import {
bannersEventHelp,
bannersImageSectionHelp,
@@ -144,12 +144,12 @@ import {
bannersShareHelp,
bannersShowHelp,
} from '@/utils/adminHelpCopy'
import { computed, onMounted, ref, watch } from 'vue'
import { onMounted, ref, watch } from 'vue'
const showThemeBanners = isModularUiVariant()
const { state, saving, save, load, saveSoon } = useAppBannersAdmin()
/** اگر اسلایدری هست، پنل را باز نگه دار تا خالی به‌نظر نرسد */
const sliderActiveKey = ref<(string | number)[]>(['slider'])
const showSettingsProfileBanners = computed(() => hasAppUiConfig())
watch(
() => state.slider.length,
+2 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="capture-sites-page">
<BasicShowContext title="لیست سایت‌های ری‌گیری">
<BasicShowContext title="لیست سایت‌های ری‌گیری" :help="captureSitesPageIntro">
<template #extra>
<div class="capture-sites-page__toolbar">
<label v-if="enabledRow" class="capture-sites-page__switch">
@@ -118,6 +118,7 @@ import {
serializeTrackingSystemsEnabled,
} from '@/utils/reygiriSites'
import { setTrackingSystemsEnabledFromSettings } from '@/utils/config'
import { captureSitesPageIntro } from '@/utils/adminHelpCopy'
const sites = ref<CaptureSite[]>([])
const loading = ref(false)
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست حساب ها">
<BasicShowContext title="لیست حساب ها" :help="cardsPageIntro">
<div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
@@ -62,6 +62,7 @@ import { changeAccountSts, getAccountTable } from '@/api/card'
import type { Card } from '@/model/card'
import AccountMobileCardList from './AccountMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { cardsPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,31 +1,35 @@
<template>
<section v-if="visible" class="colleague-accounts">
<header class="colleague-accounts__head">
<div>
<h3 class="colleague-accounts__title">کد حسابداری معامله با همکار</h3>
<p class="colleague-accounts__desc">
برای هر خرید/فروشی که نزد همکار ثبت می‌شود، سند روی این کد حساب در سیستم حسابداری فعال زده می‌شود.
</p>
</div>
<BaseButton type="primary" :loading="saving" @click="save">
ذخیره کد
</BaseButton>
</header>
<section
v-if="visible"
class="colleague-accounts w-full flex flex-col gap-2.5 rounded-lg border border-base p-2.5 sm:grid sm:grid-cols-2 sm:items-end sm:gap-3"
>
<div class="min-w-0">
<h3 class="m-0 inline-flex items-center gap-1.5 text-sm font-600" style="color: var(--admin-text)">
کد حسابداری معامله با همکار
<AdminFieldHelp :text="colleagueAccountingCodeHelp" size="sm" />
</h3>
<p class="m-0 mt-0.5 text-xs leading-5" style="color: var(--admin-text-secondary)">
سند خرید/فروش پوشش‌شده روی این کد در حسابداری فعال ثبت می‌شود.
</p>
</div>
<label
v-if="activeField"
class="colleague-accounts__field colleague-accounts__field--active"
>
<span class="colleague-accounts__label">
<label v-if="activeField" class="m-0 flex w-full min-w-0 flex-col gap-1">
<span class="inline-flex items-center gap-1.5 text-xs font-600" style="color: var(--admin-text)">
{{ activeField.label }}
<span class="colleague-accounts__badge">فعال</span>
</span>
<a-input
v-model:value="values[activeField.key]"
:placeholder="activeField.placeholder"
allow-clear
class="colleague-accounts__input"
/>
<div class="flex w-full min-w-0 items-center gap-2">
<a-input
v-model:value="values[activeField.key]"
:placeholder="activeField.placeholder"
allow-clear
size="small"
class="min-w-0 flex-1"
/>
<BaseButton type="primary" size="small" class="shrink-0" :loading="saving" @click="save">
ذخیره
</BaseButton>
</div>
</label>
</section>
</template>
@@ -35,6 +39,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { getConfiguration, setConfiguration } from '@/api/configuration'
import type { Const } from '@/model/configuration'
import { colleagueAccountingCodeHelp } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import {
accountingProviderSetting,
initWalletSettingsFromApi,
@@ -169,73 +175,14 @@ onMounted(() => {
})
</script>
<style scoped lang="less">
<style scoped>
.colleague-accounts {
margin-bottom: 1rem;
padding: 1rem 1.125rem;
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.12));
border-radius: 14px;
background: var(--admin-bg-elevated, rgba(28, 28, 34, 0.92));
}
.colleague-accounts__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.colleague-accounts__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--admin-text, #fff);
}
.colleague-accounts__desc {
margin: 0.35rem 0 0;
max-width: 42rem;
font-size: 0.75rem;
line-height: 1.7;
color: var(--admin-text-secondary, rgba(255, 255, 255, 0.65));
}
.colleague-accounts__field {
display: flex;
max-width: 28rem;
min-width: 0;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem;
border: 1px solid color-mix(in srgb, #d4af37 55%, transparent);
border-radius: 12px;
background: color-mix(in srgb, var(--admin-bg-muted, #1a1a1f) 80%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, #d4af37 18%, transparent);
}
.colleague-accounts__label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--admin-text, #fff);
background: var(--admin-bg-surface, var(--admin-bg-elevated, rgba(28, 28, 34, 0.92)));
}
.colleague-accounts__badge {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.4rem;
border-radius: 999px;
@apply inline-flex items-center rounded-full px-1.5 text-10px font-700;
background: rgba(212, 175, 55, 0.18);
color: #d4af37;
font-size: 0.6875rem;
font-weight: 700;
}
.colleague-accounts__input {
width: 100%;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="تراکنش با اپلیکیشن همکار">
<BasicShowContext title="تراکنش با اپلیکیشن همکار" :help="colleagueOrdersPageIntro">
<div class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" />
</div>
@@ -14,6 +14,7 @@ import { getColleagueOrderTable } from '@/api/order'
import { getColleagueOrderColumns } from '@/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { colleagueOrdersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="سفارش شرطی">
<BasicShowContext title="سفارش شرطی" :help="conditionalOrdersPageIntro">
<template #extra>
<div class="flex items-center gap-2">
<BaseButton type="primary" size="small" @click="emits('add')">
@@ -53,6 +53,7 @@ import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/conditionalOrdersColumns'
import { getConditionalOrdersTable } from '@/api/conditionalOrders'
import { conditionalOrdersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
+2 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0">
<BasicShowContent :title="pageTitle" class="h-full">
<BasicShowContent :title="pageTitle" :help="configurationPageIntro" class="h-full">
<div class="settings-toolbar">
<a-input
v-model:value="search"
@@ -207,6 +207,7 @@ import { loading } from '@/utils'
import { getEditableConfiguration, getSystemConfiguration, setConfiguration, setSystemConfiguration, refreshSuperAdminSettings } from '@/api/configuration'
import SettingValueField from './SettingValueField.vue'
import SettingCard from './SettingCard.vue'
import { configurationPageIntro } from '@/utils/adminHelpCopy'
import { refreshAppModeMenu } from '@/router'
import { useUserStore } from '@/store/userStore'
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyAutomationEnabledFromRows, applyProductFundsEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applyPriceChannelAccessFromRows, applySupportChatMenuEnabledFromRows, applyGroupSmsFromRows, applyCustomerSpecialPricingFromRows, applyGoldCreditEnabledFromRows, applyCustomerDirectSmsFromRows, applyCustomerLevelProfitEnabledFromRows, applySuperAdminAccessFlagsFromItems, invalidateSettingsBootstrap, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setAutomationEnabledFromSettings, setProductFundsEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setPriceChannelAccessFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings, setGroupSmsEnabledFromSettings, setCustomerSpecialPricingEnabledFromSettings, setGoldCreditEnabledFromSettings } from '@/utils/config'
@@ -20,7 +20,7 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
title: 'عمومی و برند',
icon: 'vuesax-linear:home-2',
match: (k) =>
/^(name_fa|name_en|bace_url|server_url|logo_|title_pwa|title_fav|open_register|auto_active_customer|store_status|key$|enamad_url|enamad_logo)$/.test(
/^(name_fa|name_en|bace_url|server_url|logo_|title_pwa|title_fav|open_register|auto_active_customer|store_status|key$|enamad_url|enamad_logo|login_hero_)/.test(
k
)
},
@@ -174,7 +174,7 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'عمومی و برند',
icon: 'vuesax-linear:home-2',
match: (k) =>
/^(name_fa|name_en|app_url|admin_url|apk_download_link|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|sms_panel|main_wallet_products|enamad_url|enamad_logo)/.test(
/^(name_fa|name_en|app_url|admin_url|apk_download_link|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|sms_panel|main_wallet_products|enamad_url|enamad_logo|login_hero_)/.test(
k
)
},
@@ -292,6 +292,12 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
about_software: 'longtext',
enamad_url: 'text',
enamad_logo: 'text',
login_hero_eyebrow: 'text',
login_hero_title: 'text',
login_hero_feature_1: 'text',
login_hero_feature_2: 'text',
login_hero_feature_3: 'text',
login_hero_feature_4: 'text',
fee_service_charge_percentage: 'number',
fee_deduct_from_gold: 'switch',
require_zero_main_gold_before_other_buy: 'switch',
@@ -667,8 +673,20 @@ export function parseSwitchHelp(help: string | undefined | null): SwitchHelpPart
const KEY_HELP_FALLBACK: Record<string, string> = {
name_fa:
'نام فارسی برند که در پیامک‌ها، عنوان صفحات و بخش‌های عمومی اپ به مشتری نشان داده می‌شود.',
login_hero_eyebrow:
'متن بالای عنوان طلا در ستون چپ صفحه ورود دسکتاپ (تم VIP). خالی = پیش‌فرض «سرمایه‌گذاری هوشمند در».',
login_hero_title:
'عنوان طلایی ستون چپ صفحه ورود دسکتاپ (تم VIP). خالی = پیش‌فرض «طلای جهانی».',
login_hero_feature_1:
'اولین آیتم لیست ویژگی‌های هیرو لاگین دسکتاپ. خالی = «خرید و فروش آنلاین طلا».',
login_hero_feature_2:
'دومین آیتم لیست ویژگی‌های هیرو لاگین دسکتاپ. خالی = «قیمت لحظه‌ای و دقیق».',
login_hero_feature_3:
'سومین آیتم لیست ویژگی‌های هیرو لاگین دسکتاپ. خالی = «امنیت بالا و قابل اطمینان».',
login_hero_feature_4:
'چهارمین آیتم لیست ویژگی‌های هیرو لاگین دسکتاپ. خالی = «پشتیبانی ۲۴/۷».',
banner_enabled:
'روشن: منوی «بنرها» در ادمین و اسلایدر صفحه اصلی اپ مشتری نمایش داده می‌شود. خاموش: مخفی است.',
'روشن: منوی «بنرها» در ادمین باز می‌شود (اسلایدر صفحه اصلی + تصاویر تم مثل بنر اشتراک‌گذاری/کد معرف). خاموش: منو مخفی است.',
permision__enabled:
'روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.',
open_register:
@@ -1037,6 +1055,12 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
show_product_card_meta: 'نمایش دسته و تحویل و مقدار روی کارت محصول',
enamad_url: 'آدرس اینماد',
enamad_logo: 'لوگوی اینماد',
login_hero_eyebrow: 'خط اول هیرو لاگین',
login_hero_title: 'عنوان هیرو لاگین',
login_hero_feature_1: 'ویژگی ۱ هیرو لاگین',
login_hero_feature_2: 'ویژگی ۲ هیرو لاگین',
login_hero_feature_3: 'ویژگی ۳ هیرو لاگین',
login_hero_feature_4: 'ویژگی ۴ هیرو لاگین',
fee_single: 'اختلاف پایه خرید/فروش',
buy_fee: 'اضافه قیمت خرید',
sell_fee: 'کسر قیمت فروش',
@@ -1268,6 +1292,8 @@ export const SETTING_EDITABLE_KEYS = new Set([
'do_send_liman',
'has_chat',
'text_about', 'text_rule', 'about_software', 'enamad_url', 'enamad_logo',
'login_hero_eyebrow', 'login_hero_title',
'login_hero_feature_1', 'login_hero_feature_2', 'login_hero_feature_3', 'login_hero_feature_4',
'zaarbox_price_key',
'price_channel_enabled', 'price_channel_messengers', 'price_channel_diff',
'price_channel_min_seconds', 'price_channel_footer_id', 'price_channel_footer_phone',
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست مشتریان بایگانی شده">
<BasicShowContext title="لیست مشتریان بایگانی شده" :help="customersArchivePageIntro">
<div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
@@ -51,6 +51,7 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is'
import { customersArchivePageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'charge'])
@@ -9,7 +9,13 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-3xl font-bold text-gray-800 mb-2">احراز هویت</h2>
<h2 class="text-3xl font-bold text-gray-800 mb-2 inline-flex items-center justify-center gap-1.5">
احراز هویت
<AdminFieldHelp
text="با کد ملی و اطلاعات کارت بانکی، هویت فرد را از طریق سرویس استعلام بانکی بررسی می‌کند. نتیجه برای تایید مشتری در پنل استفاده می‌شود."
size="sm"
/>
</h2>
<p class="text-gray-500 text-sm">لطفاً اطلاعات کارت بانکی فرد مورد نظر را وارد کنید</p>
</div>
@@ -93,6 +99,7 @@ import type { CheckCardRequest } from "@/model/user"
import { checkCard } from "@/api/customer"
import DatePicker from "@core/components/_sample/components/datePicker.vue";
import Icon from "@core/components/BaseIcon/src/Icon.vue";
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
const formState = reactive<CheckCardRequest>({
// birth_date: '',
@@ -27,21 +27,26 @@
برای پاک کردن کد، فیلد را خالی بگذارید و ذخیره کنید.
</p>
<p
v-if="provider === 'tahesab'"
v-if="provider === 'tahesab' || provider === 'zarin'"
class="m-0 text-xs"
style="color: var(--admin-text-secondary)"
>
بعد از ذخیره کد ته‌حساب، اگر موجودی اپ با مانده ته‌حساب فرق داشته باشد از شما تصمیم گرفته می‌شود.
قبل از ذخیره، اگر مانده اپ با حسابداری فرق داشته باشد از شما تأیید گرفته می‌شود؛ بعد اعمال می‌شود.
</p>
</div>
</BasicModal>
<TahesabMandeDecisionModal
v-model:visible="decisionVisible"
:source-label="provider === 'zarin' ? 'زرین' : 'ته‌حساب'"
:app-gold="decision?.app_gold"
:app-cash="decision?.app_cash"
:tahesab-gold="decision?.tahesab_gold"
:tahesab-cash="decision?.tahesab_cash"
:tahesab-gold="decision?.tahesab_gold ?? decision?.zarin_gold"
:tahesab-cash="decision?.tahesab_cash ?? decision?.zarin_cash"
:gold-delta="decision?.gold_delta"
:rial-delta="decision?.rial_delta"
:wallet-docs="decision?.wallet_docs"
:tahesab-docs="decision?.tahesab_docs"
@decide="onDecide"
/>
</template>
@@ -52,9 +57,14 @@ import { message } from 'ant-design-vue'
import BasicModal from '@/components/BasicModal.vue'
import { updateCustomer } from '@/api/customer'
import {
previewCustomerWalletFromTahesab,
syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult,
} from '@/api/tahesab'
import {
previewCustomerWalletFromZarin,
syncCustomerWalletFromZarin,
} from '@/api/zarin'
import type { Customer } from '@/model/customer'
import type { AccountingProvider } from '@/utils/config'
import { displayAccountingCode, normalizeAccountingCode } from '@/utils/accountingCode'
@@ -76,7 +86,7 @@ const loading = ref(false)
const code = ref('')
const decisionVisible = ref(false)
const decision = ref<TahesabWalletSyncResult['result'] | null>(null)
const savedCustomerId = ref<number | null>(null)
const pendingPayload = ref<{ customerId: number; body: Record<string, string>; nextCode: string } | null>(null)
const visibleProxy = computed({
get: () => props.visible,
@@ -103,47 +113,44 @@ function close() {
visibleProxy.value = false
}
async function maybeSyncTahesabWallet(customerId: number, previousCode: string, nextCode: string) {
if (props.provider !== 'tahesab') return
if (!nextCode || nextCode === previousCode) return
try {
const res = await syncCustomerWalletFromTahesab(customerId, { ask_if_conflict: true })
if (res.needs_decision || res.result?.needs_decision) {
decision.value = res.result ?? null
savedCustomerId.value = customerId
decisionVisible.value = true
return
}
if (res.result?.applied) {
message.success(res.message || 'مانده ته‌حساب روی کیف اعمال شد')
} else if (res.message) {
message.info(res.message)
}
} catch (e) {
// خطای همگام‌سازی مانع ذخیره کد نیست
console.error(e)
}
async function saveCode(customerId: number, body: Record<string, string>) {
await updateCustomer(customerId, body as Customer)
message.success('کد حسابداری ذخیره شد.')
emit('success')
close()
}
async function onDecide(mode: 'overwrite' | 'keep') {
const customerId = savedCustomerId.value
decisionVisible.value = false
if (!customerId) return
try {
const res = await syncCustomerWalletFromTahesab(customerId, {
async function applyWalletDecision(customerId: number, mode: 'overwrite' | 'keep') {
if (props.provider === 'zarin') {
return syncCustomerWalletFromZarin(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
ask_if_conflict: false,
})
if (mode === 'keep') {
message.info(res.message || 'موجودی اپ حفظ شد')
} else {
message.success(res.message || 'مانده ته‌حساب روی کیف اعمال شد')
}
}
return syncCustomerWalletFromTahesab(customerId, {
overwrite: mode === 'overwrite',
keep: mode === 'keep',
ask_if_conflict: false,
})
}
async function onDecide(mode: 'overwrite' | 'keep') {
const pending = pendingPayload.value
decisionVisible.value = false
if (!pending) return
try {
loading.value = true
await saveCode(pending.customerId, pending.body)
const res = await applyWalletDecision(pending.customerId, mode)
if (mode === 'keep') message.info(res.message || 'موجودی اپ حفظ شد')
else message.success(res.message || 'مانده حسابداری روی کیف اعمال شد')
emit('success')
} catch (e) {
console.error(e)
} finally {
loading.value = false
pendingPayload.value = null
}
}
@@ -161,13 +168,38 @@ async function submit() {
? { pargar_account_id: normalized }
: { moshtari_code: normalized }
const needsWalletAsk =
(props.provider === 'tahesab' || props.provider === 'zarin')
&& !!normalized
&& normalized !== previousCode
try {
loading.value = true
await updateCustomer(props.customer.id, payload as Customer)
message.success('کد حسابداری ذخیره شد.')
emit('success')
close()
await maybeSyncTahesabWallet(props.customer.id, previousCode, normalized)
if (needsWalletAsk) {
try {
const preview = props.provider === 'zarin'
? await previewCustomerWalletFromZarin(props.customer.id, normalized)
: await previewCustomerWalletFromTahesab(props.customer.id, normalized)
if (preview.needs_decision || preview.result?.needs_decision) {
pendingPayload.value = {
customerId: props.customer.id,
body: payload as Record<string, string>,
nextCode: normalized,
}
decision.value = preview.result ?? null
decisionVisible.value = true
return
}
if (preview.ok === false && preview.message) {
message.warning(preview.message + ' — فقط کد ذخیره می‌شود')
}
} catch {
message.warning('مانده حسابداری خوانده نشد — فقط کد ذخیره می‌شود')
}
}
await saveCode(props.customer.id, payload as Record<string, string>)
} catch (e) {
console.error(e)
} finally {
@@ -1,6 +1,6 @@
<template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext title="لاگ‌ها و اسناد پرگار">
<BasicShowContext title="لاگ‌ها و اسناد پرگار" :help="customerPargarLogsIntro">
<div class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
@@ -110,6 +110,7 @@ import {
pargarTypeLabel
} from '@/pages/menuPages/accounting/utils/pargarDisplay'
import { toJalali } from '@core/utils/useDataTime'
import { customerPargarLogsIntro } from '@/utils/adminHelpCopy'
const props = defineProps<{
customerId?: number
@@ -1,7 +1,7 @@
<template>
<BasicModal
v-model:visible="visibleProxy"
:width="isMobile ? '92vw' : '28rem'"
:width="isMobile ? '92vw' : '36rem'"
title="تصمیم برای مانده حساب"
:custom-footer="true"
cancel-text="بعداً"
@@ -9,7 +9,7 @@
>
<div class="flex flex-col gap-3 text-sm" style="color: var(--admin-text)">
<p class="m-0" style="color: var(--admin-text-secondary)">
موجودی اپ با مانده ته‌حساب فرق دارد. کدام را اعمال کنیم؟
موجودی اپ با مانده {{ sourceLabel }} فرق دارد. دو مانده جمع نمی‌شوند؛ یکی را پس از بررسی سندها تأیید کنید.
</p>
<div class="rounded-lg border border-base p-3 space-y-2" style="background: var(--admin-bg-muted)">
<div class="flex justify-between gap-2">
@@ -17,8 +17,12 @@
<strong dir="ltr">{{ formatGold(appGold) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (طلا)</span>
<strong dir="ltr">{{ formatGold(tahesabGold) }}</strong>
<span style="color: var(--admin-text-secondary)">مانده {{ sourceLabel }} (طلا)</span>
<strong dir="ltr">{{ formatGold(sourceGoldValue) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">اختلاف طلا</span>
<strong dir="ltr">{{ formatDeltaGold(goldDeltaValue) }}</strong>
</div>
<div class="h-px" style="background: var(--admin-border)" />
<div class="flex justify-between gap-2">
@@ -26,13 +30,62 @@
<strong dir="ltr">{{ formatCash(appCash) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (ریال)</span>
<strong dir="ltr">{{ formatCash(tahesabCash) }}</strong>
<span style="color: var(--admin-text-secondary)">مانده {{ sourceLabel }} (ریال)</span>
<strong dir="ltr">{{ formatCash(sourceCashValue) }}</strong>
</div>
<div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">اختلاف ریال</span>
<strong dir="ltr">{{ formatDeltaCash(rialDeltaValue) }}</strong>
</div>
</div>
<div v-if="showDocs" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="rounded-lg border border-base p-2.5 min-w-0">
<p class="m-0 mb-1.5 text-xs font-600">سندهای کیف اپ</p>
<p class="m-0 mb-1.5 text-xs" style="color: var(--admin-text-secondary)">آخرین تراکنش‌های کیف اصلی</p>
<div v-if="walletDocs.length" class="max-h-32 overflow-y-auto flex flex-col gap-1.5">
<div
v-for="doc in walletDocs"
:key="'w-' + doc.id"
class="rounded-md border border-base px-2 py-1.5 text-xs"
>
<div class="font-600 truncate">{{ doc.title || 'تراکنش' }}</div>
<div class="flex justify-between gap-2 mt-0.5" style="color: var(--admin-text-secondary)">
<span>{{ formatDocDate(doc.created_at) }}</span>
<span dir="ltr">{{ formatGold(doc.gold) }} طلا · {{ formatCash(doc.cash) }} ریال</span>
</div>
</div>
</div>
<p v-else class="m-0 text-xs" style="color: var(--admin-text-secondary)">سندی در کیف ثبت نشده</p>
</div>
<div class="rounded-lg border border-base p-2.5 min-w-0">
<p class="m-0 mb-1.5 text-xs font-600">سندهای {{ sourceLabel }}</p>
<p class="m-0 mb-1.5 text-xs" style="color: var(--admin-text-secondary)">آخرین اسناد حسابداری این مشتری</p>
<div v-if="sourceDocs.length" class="max-h-32 overflow-y-auto flex flex-col gap-1.5">
<div
v-for="doc in sourceDocs"
:key="'s-' + doc.id"
class="rounded-md border border-base px-2 py-1.5 text-xs"
>
<div class="font-600 truncate">{{ doc.title || 'سند' }}</div>
<div class="flex justify-between gap-2 mt-0.5" style="color: var(--admin-text-secondary)">
<span>{{ formatDocDate(doc.date || doc.created_at) }}</span>
<span dir="ltr">
<template v-if="doc.gold != null || doc.cash != null">
{{ formatGold(doc.gold) }} طلا · {{ formatCash(doc.cash) }} ریال
</template>
<template v-else>{{ formatGold(doc.amount) }} · {{ doc.type || '—' }}</template>
</span>
</div>
</div>
</div>
<p v-else class="m-0 text-xs" style="color: var(--admin-text-secondary)">سندی از حسابداری نیست</p>
</div>
</div>
<div class="flex flex-col gap-2">
<a-button type="primary" block :loading="loading === 'overwrite'" :disabled="!!loading" @click="choose('overwrite')">
اعمال مانده ته‌حساب روی کیف
اعمال مانده {{ sourceLabel }} روی کیف
</a-button>
<a-button block :loading="loading === 'keep'" :disabled="!!loading" @click="choose('keep')">
نگه داشتن موجودی فعلی اپ
@@ -48,6 +101,7 @@
import { computed, ref, watch } from 'vue'
import BasicModal from '@/components/BasicModal.vue'
import { isMobile } from '@core/utils/is'
import type { TahesabPreviewDoc } from '@/api/tahesab'
const props = defineProps<{
visible: boolean
@@ -55,6 +109,13 @@ const props = defineProps<{
appCash?: number | null
tahesabGold?: number | null
tahesabCash?: number | null
sourceGold?: number | null
sourceCash?: number | null
goldDelta?: number | null
rialDelta?: number | null
sourceLabel?: string
walletDocs?: TahesabPreviewDoc[]
tahesabDocs?: TahesabPreviewDoc[]
}>()
const emit = defineEmits<{
@@ -63,6 +124,22 @@ const emit = defineEmits<{
}>()
const loading = ref<'overwrite' | 'keep' | null>(null)
const sourceLabel = computed(() => props.sourceLabel || 'ته‌حساب')
const sourceGoldValue = computed(() => props.sourceGold ?? props.tahesabGold)
const sourceCashValue = computed(() => props.sourceCash ?? props.tahesabCash)
const goldDeltaValue = computed(() => {
if (props.goldDelta != null && !Number.isNaN(Number(props.goldDelta))) return Number(props.goldDelta)
return Number(sourceGoldValue.value ?? 0) - Number(props.appGold ?? 0)
})
const rialDeltaValue = computed(() => {
if (props.rialDelta != null && !Number.isNaN(Number(props.rialDelta))) return Number(props.rialDelta)
return Number(sourceCashValue.value ?? 0) - Number(props.appCash ?? 0)
})
const walletDocs = computed(() => props.walletDocs ?? [])
const sourceDocs = computed(() => props.tahesabDocs ?? [])
const showDocs = computed(
() => sourceLabel.value === 'ته‌حساب' || walletDocs.value.length > 0 || sourceDocs.value.length > 0
)
const visibleProxy = computed({
get: () => props.visible,
@@ -86,6 +163,25 @@ function formatCash(v?: number | null) {
return Math.round(Number(v)).toLocaleString('fa-IR')
}
function formatDeltaGold(v: number) {
if (Number.isNaN(v)) return '—'
const sign = v > 0 ? '+' : ''
return sign + formatGold(v)
}
function formatDeltaCash(v: number) {
if (Number.isNaN(v)) return '—'
const sign = v > 0 ? '+' : ''
return sign + formatCash(v)
}
function formatDocDate(value?: string | null) {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('fa-IR')
}
function close() {
visibleProxy.value = false
}
@@ -1,15 +1,15 @@
<template>
<div class="tahesab-wallet-sync">
<div class="tahesab-wallet-sync" :class="{ 'tahesab-wallet-sync--row': layout === 'row' }">
<div class="tahesab-wallet-sync__head">
<div class="tahesab-wallet-sync__icon" aria-hidden="true">
<Icon icon="vuesax-linear:refresh-circle" :size="18" color="var(--admin-primary, #10913A)" />
</div>
<div class="tahesab-wallet-sync__copy min-w-0 flex-1">
<p class="tahesab-wallet-sync__title">همگام‌سازی با ته‌حساب</p>
<p class="tahesab-wallet-sync__title">همگام‌سازی مانده</p>
<p class="tahesab-wallet-sync__hint">
{{
canSync
? 'مانده طلا و ریال ته‌حساب را با کیف اپ مقایسه می‌کند؛ اگر تداخل باشد شما تصمیم می‌گیرید'
? 'اول مانده و سندها را می‌بینید؛ بعد تأیید می‌کنید. دو مانده جمع نمی‌شوند'
: 'ابتدا کد حسابداری ته‌حساب را برای این مشتری ثبت کنید'
}}
</p>
@@ -17,9 +17,9 @@
</div>
<a-button
type="primary"
block
size="small"
class="tahesab-wallet-sync__btn"
:block="layout !== 'row'"
:loading="loading"
:disabled="!canSync || loading"
@click="$emit('sync', 'ask')"
@@ -32,10 +32,14 @@
<script setup lang="ts">
import Icon from '@core/components/BaseIcon/src/Icon.vue'
defineProps<{
loading?: boolean
canSync?: boolean
}>()
withDefaults(
defineProps<{
loading?: boolean
canSync?: boolean
layout?: 'stack' | 'row'
}>(),
{ layout: 'stack' },
)
defineEmits<{
(e: 'sync', mode: 'ask' | 'overwrite' | 'keep'): void
@@ -48,6 +52,24 @@ defineEmits<{
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc));
}
.tahesab-wallet-sync--row {
@apply flex flex-col gap-2.5;
}
.tahesab-wallet-sync--row .tahesab-wallet-sync__head {
@apply mb-0 min-w-0 flex-1;
}
@media (min-width: 640px) {
.tahesab-wallet-sync--row {
@apply flex-row items-center gap-3;
}
.tahesab-wallet-sync--row .tahesab-wallet-sync__btn {
@apply shrink-0;
}
}
.tahesab-wallet-sync__head {
@apply mb-2.5 flex items-start gap-2;
}
@@ -0,0 +1,72 @@
<template>
<div class="zarin-wallet-sync">
<div class="zarin-wallet-sync__head">
<div class="zarin-wallet-sync__icon" aria-hidden="true">
<Icon icon="vuesax-linear:refresh-circle" :size="18" color="var(--admin-primary, #10913A)" />
</div>
<div class="zarin-wallet-sync__copy min-w-0 flex-1">
<p class="zarin-wallet-sync__title">همگام‌سازی با زرین</p>
<p class="zarin-wallet-sync__hint">
{{
canSync
? 'مانده طلا و ریال زرین را با کیف اپ مقایسه می‌کند؛ اگر تداخل باشد شما تصمیم می‌گیرید'
: 'ابتدا کد حسابداری زرین را برای این مشتری ثبت کنید'
}}
</p>
</div>
</div>
<a-button
type="primary"
block
size="small"
class="zarin-wallet-sync__btn"
:loading="loading"
:disabled="!canSync || loading"
@click="$emit('sync', 'ask')"
>
بررسی و همگام‌سازی مانده
</a-button>
</div>
</template>
<script setup lang="ts">
import Icon from '@core/components/BaseIcon/src/Icon.vue'
defineProps<{
loading?: boolean
canSync?: boolean
}>()
defineEmits<{
(e: 'sync', mode: 'ask' | 'overwrite' | 'keep'): void
}>()
</script>
<style scoped>
.zarin-wallet-sync {
@apply mb-2.5 rounded-xl border border-base p-2.5;
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc));
}
.zarin-wallet-sync__head {
@apply mb-2.5 flex items-start gap-2;
}
.zarin-wallet-sync__icon {
@apply flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50;
}
.zarin-wallet-sync__title {
@apply m-0 text-sm font-700;
color: var(--admin-text, inherit);
}
.zarin-wallet-sync__hint {
@apply m-0 mt-0.5 text-xs leading-5;
color: var(--admin-text-secondary, #6b7280);
}
.zarin-wallet-sync__btn {
@apply font-600;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="لیست دارایی ها">
<BasicShowContext title="لیست دارایی ها" :help="customerFundsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex items-center justify-center gap-2">
@@ -64,6 +64,7 @@ import {
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import BasicShowContext from '@/components/BasicShowContext.vue'
import { toJalali } from '@core/utils/useDataTime'
import { customerFundsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const [assetFundsSubmit] = useBaseTable()
@@ -48,7 +48,7 @@
<BaseIcon icon="vuesax-linear:medal-star" Size="16px" />
</BaseButton>
</div>
<BasicShowContext title="لیست اقساط">
<BasicShowContext title="لیست اقساط" :help="goldCreditInstallmentsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl" />
</BasicShowContext>
</div>
@@ -59,6 +59,7 @@ import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { cols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns'
import { getCustomerGoldCreditInstallments, type GoldCredit } from '@/api/goldCredit'
import { goldCreditInstallmentsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="لیست قلک ها">
<BasicShowContext title="لیست قلک ها" :help="customerPiggyFundsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex items-center justify-center gap-2">
@@ -45,6 +45,7 @@ import { computed, ref } from 'vue'
import { getPiggyFundsTable, getPiggyFundTransactionsTable } from '@/api/user'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import BasicShowContext from '@/components/BasicShowContext.vue'
import { customerPiggyFundsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const [txSubmit] = useBaseTable()
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="لیست معرفی کرده ها">
<BasicShowContext title="لیست معرفی کرده ها" :help="customerReferralsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
</BaseTable>
</BasicShowContext>
@@ -28,6 +28,7 @@ import {toJalaliNoTime} from '@core/utils/useDataTime'
import type {Customer} from '@/model/customer'
import {onMounted} from 'vue'
import {getReferralsTable} from "@/api/user";
import { customerReferralsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست مشتریان">
<BasicShowContext title="لیست مشتریان" :help="customersPageIntro">
<template #extra>
<BaseButton
v-if="isMobile"
@@ -273,6 +273,7 @@ import {
initWalletSettingsFromApi
} from '@/utils/config'
import { displayAccountingCode, isValidAccountingCode } from '@/utils/accountingCode'
import { customersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'chargeWallet'])
+107 -6
View File
@@ -203,6 +203,12 @@
:can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet"
/>
<ZarinWalletSyncAction
v-if="zarinWalletSyncVisible"
:loading="zarinSyncLoading"
:can-sync="zarinWalletCanSync"
@sync="syncZarinWallet"
/>
<CustomerWalletBalancePanel
:key="`wallet-mobile-${walletPanelKey}`"
ref="walletPanelMobile"
@@ -230,6 +236,12 @@
:can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet"
/>
<ZarinWalletSyncAction
v-if="zarinWalletSyncVisible"
:loading="zarinSyncLoading"
:can-sync="zarinWalletCanSync"
@sync="syncZarinWallet"
/>
<CustomerWalletBalancePanel
:key="`wallet-desktop-${walletPanelKey}`"
ref="walletPanelDesktop"
@@ -262,6 +274,12 @@
<CustomerPargarLogsTable ref="pargarLogsTable" :customer-id="userInfo?.id" />
</a-tab-pane>
<a-tab-pane v-if="tahesabDocsVisible" key="اسناد ته‌حساب" tab="اسناد ته‌حساب">
<TahesabWalletSyncAction
layout="row"
:loading="tahesabSyncLoading"
:can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet"
/>
<CustomerTahesabDocsTable ref="tahesabDocsTable" :customer-id="userInfo?.id" />
</a-tab-pane>
<a-tab-pane v-if="zarinDocsVisible" key="اسناد زرین" tab="اسناد زرین">
@@ -289,10 +307,15 @@
/>
<TahesabMandeDecisionModal
v-model:visible="tahesabDecisionVisible"
:source-label="walletDecisionLabel"
:app-gold="tahesabDecision?.app_gold"
:app-cash="tahesabDecision?.app_cash"
:tahesab-gold="tahesabDecision?.tahesab_gold"
:tahesab-cash="tahesabDecision?.tahesab_cash"
:tahesab-gold="tahesabDecision?.tahesab_gold ?? tahesabDecision?.zarin_gold"
:tahesab-cash="tahesabDecision?.tahesab_cash ?? tahesabDecision?.zarin_cash"
:gold-delta="tahesabDecision?.gold_delta"
:rial-delta="tahesabDecision?.rial_delta"
:wallet-docs="tahesabDecision?.wallet_docs"
:tahesab-docs="tahesabDecision?.tahesab_docs"
@decide="onTahesabMandeDecide"
/>
<BasicInfoModal
@@ -359,11 +382,17 @@ import { displayAccountingCode, isValidAccountingCode } from '@/utils/accounting
import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue'
import { syncCustomerWalletFromPargar, type PargarSyncWalletResult } from '@/api/pargar'
import {
previewCustomerWalletFromTahesab,
syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult,
} from '@/api/tahesab'
import {
syncCustomerWalletFromZarin,
type ZarinWalletSyncResult,
} from '@/api/zarin'
import PargarWalletSyncAction from './components/PargarWalletSyncAction.vue'
import TahesabWalletSyncAction from './components/TahesabWalletSyncAction.vue'
import ZarinWalletSyncAction from './components/ZarinWalletSyncAction.vue'
import TahesabMandeDecisionModal from './components/TahesabMandeDecisionModal.vue'
const fnameHelp: DashboardHelp = {
@@ -761,8 +790,11 @@ const accountingCodeValue = computed(() => {
const pargarSyncLoading = ref(false)
const tahesabSyncLoading = ref(false)
const zarinSyncLoading = ref(false)
const tahesabDecisionVisible = ref(false)
const tahesabDecision = ref<TahesabWalletSyncResult['result'] | null>(null)
const tahesabDecision = ref<(TahesabWalletSyncResult['result'] & { zarin_gold?: number; zarin_cash?: number }) | null>(null)
const walletDecisionProvider = ref<'tahesab' | 'zarin'>('tahesab')
const walletDecisionLabel = computed(() => walletDecisionProvider.value === 'zarin' ? 'زرین' : 'ته‌حساب')
const walletPanelDesktop = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelMobile = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelKey = ref(0)
@@ -776,6 +808,10 @@ const tahesabWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'tahesab'
)
const zarinWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'zarin'
)
const tahesabDocsVisible = computed(() => accountingProviderSetting.value === 'tahesab')
const zarinDocsVisible = computed(() => accountingProviderSetting.value === 'zarin')
@@ -787,6 +823,10 @@ const tahesabWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.moshtari_code)
)
const zarinWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.moshtari_code)
)
async function refreshWalletPanels() {
await nextTick()
walletPanelKey.value += 1
@@ -797,7 +837,8 @@ async function refreshWalletPanels() {
])
}
function openTahesabDecision(result?: TahesabWalletSyncResult['result']) {
function openTahesabDecision(result?: TahesabWalletSyncResult['result'], provider: 'tahesab' | 'zarin' = 'tahesab') {
walletDecisionProvider.value = provider
tahesabDecision.value = result ?? null
tahesabDecisionVisible.value = true
}
@@ -806,14 +847,28 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
if (!userInfo.value?.id || !tahesabWalletCanSync.value || tahesabSyncLoading.value) return
tahesabSyncLoading.value = true
try {
if (mode === 'ask') {
const preview = await previewCustomerWalletFromTahesab(userInfo.value.id)
if (!preview.ok && !preview.result?.ok) {
message.warning(preview.message || 'مانده از ته‌حساب خوانده نشد')
return
}
if (!preview.needs_decision && !preview.result?.needs_decision) {
message.info(preview.message || 'مانده با ته‌حساب یکسان است')
return
}
openTahesabDecision(preview.result, 'tahesab')
return
}
const res = await syncCustomerWalletFromTahesab(userInfo.value.id, {
ask_if_conflict: mode === 'ask',
ask_if_conflict: false,
overwrite: mode === 'overwrite',
keep: mode === 'keep',
})
if (res.needs_decision || res.result?.needs_decision) {
openTahesabDecision(res.result)
openTahesabDecision(res.result, 'tahesab')
return
}
@@ -846,9 +901,55 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
async function onTahesabMandeDecide(mode: 'overwrite' | 'keep') {
tahesabDecisionVisible.value = false
if (walletDecisionProvider.value === 'zarin') {
await syncZarinWallet(mode)
return
}
await syncTahesabWallet(mode)
}
async function syncZarinWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
if (!userInfo.value?.id || !zarinWalletCanSync.value || zarinSyncLoading.value) return
zarinSyncLoading.value = true
try {
const res = await syncCustomerWalletFromZarin(userInfo.value.id, {
ask_if_conflict: mode === 'ask',
overwrite: mode === 'overwrite',
keep: mode === 'keep',
})
if (res.needs_decision || res.result?.needs_decision) {
openTahesabDecision(res.result, 'zarin')
return
}
if (res.result?.applied || res.ok) {
message.success(res.message || 'مانده از زرین روی کیف اعمال شد')
} else {
message.info(res.message || 'تغییری برای اعمال نبود')
}
await refreshWalletPanels()
refreshTransactionTable()
refreshTable()
} catch (e: unknown) {
const data = (e as { response?: { data?: ZarinWalletSyncResult } })?.response?.data
const status = (e as { response?: { status?: number } })?.response?.status
await refreshWalletPanels()
refreshTransactionTable()
refreshTable()
if (data?.result?.applied || data?.ok) {
message.success(data?.message || 'مانده از زرین روی کیف اعمال شد')
} else if (status !== 422) {
message.warning(data?.message || 'اعمال مانده از زرین ناموفق بود')
}
} finally {
zarinSyncLoading.value = false
}
}
async function syncPargarWallet(force = false) {
if (!userInfo.value?.id || !pargarWalletCanSync.value || pargarSyncLoading.value) return
pargarSyncLoading.value = true
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 w-full">
<BasicShowContext title="اعتبار">
<BasicShowContext title="اعتبار" :help="goldCreditPageIntro">
<a-tabs v-model:activeKey="activeTab" class="gold-credit-tabs h-full" destroy-inactive-tab-pane>
<a-tab-pane key="requests" tab="درخواست‌ها">
<GoldCreditRequestsTab />
@@ -17,6 +17,7 @@
import { ref } from 'vue'
import GoldCreditRequestsTab from './requests/index.vue'
import GoldCreditActiveTab from './active/index.vue'
import { goldCreditPageIntro } from '@/utils/adminHelpCopy'
const activeTab = ref('requests')
</script>
@@ -3,7 +3,10 @@
<div class="online-page__hero shrink-0 mb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="online-page__title m-0">کاربران آنلاین</h2>
<h2 class="online-page__title m-0 inline-flex items-center gap-1.5">
کاربران آنلاین
<AdminFieldHelp :text="customersOnlinePageIntro" size="sm" />
</h2>
</div>
<div class="flex items-center gap-2">
<div class="online-page__count">
@@ -297,6 +300,8 @@ import { toJalali, toJalaliNoTime } from '@core/utils/useDataTime'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import { isMobile } from '@core/utils/is'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { customersOnlinePageIntro } from '@/utils/adminHelpCopy'
import { MODAL_ABOVE_ACTION_SHEET_Z_INDEX } from '@/constants/overlayZIndex'
import { inputLenRules, inputRules } from '@/utils/inputRules'
import CustomerMobileDetailSheet from '@/pages/menuPages/customers/base/components/CustomerMobileDetailSheet.vue'
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="پکیج‌های قیمت ویژه">
<BasicShowContext title="پکیج‌های قیمت ویژه" :help="specialPricePageIntro">
<template v-if="isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')">
افزودن
@@ -55,6 +55,7 @@ import { getCustomerSpecialPricePackageTable } from '@/api/customerSpecialPrice'
import { getSpecialPricePackageColumns } from '../hooks/specialPriceColumns'
import { isMobile } from '@core/utils/is'
import { ref } from 'vue'
import { specialPricePageIntro } from '@/utils/adminHelpCopy'
const emits = defineEmits<{
(e: 'add'): void
@@ -1,6 +1,6 @@
<template>
<div>
<BasicShowContext title="لیست تخفیفات">
<BasicShowContext title="لیست تخفیفات" :help="discountPageIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
<BaseButton type="primary" size="small" class="flex" @click="emits('add')">
@@ -29,6 +29,7 @@ import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/discountColumns'
import { getDiscountTable } from '@/api/discount'
import { discountPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست کارت های هدیه">
<BasicShowContext title="لیست کارت های هدیه" :help="giftCardsPageIntro">
<template v-if="!customerId && isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:receipt-add" @click="emits('add', 'gift')">
افزودن
@@ -65,6 +65,7 @@ import { getGiftCardTable } from '@/api/giftCard'
import GiftCardMobileCardList from './GiftCardMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { isStoreApp } from '@/utils/config'
import { giftCardsPageIntro } from '@/utils/adminHelpCopy'
interface Prop {
customerId?: number
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست سفارشات">
<BasicShowContext title="لیست سفارشات" :help="ordersPageIntro">
<template v-if="!customerId && isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:receipt-add" @click="emits('add')">
افزودن
@@ -49,6 +49,7 @@ import { message } from 'ant-design-vue'
import { getOrderTable } from '@/api/order'
import OrderMobileCardList from './OrderMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { ordersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,9 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست برداشت فیزیکی">
<template #extra>
<AdminFieldHelp :text="physicalWithdrawPageIntro" />
</template>
<BasicShowContext title="لیست برداشت فیزیکی" :help="physicalWithdrawPageIntro">
<div class="flex h-full min-h-0 flex-col">
<BaseTable class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
@@ -89,7 +86,6 @@ import { updateSts } from '@/api/wallet'
import { message } from 'ant-design-vue'
import type { Withdraw } from '@/model/wallet'
import { status as physicalStatusMap } from '@/pages/menuPages/withdraw/information/hooks/status'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import PhysicalWithdrawAdjustModal from './PhysicalWithdrawAdjustModal.vue'
import {
physicalWithdrawApproveHelp,
@@ -1,14 +1,11 @@
<template>
<div>
<BasicShowContext title="هشدار قیمت">
<BasicShowContext title="هشدار قیمت" :help="priceTriggersPageIntro">
<template #extra>
<div class="flex items-center gap-2">
<AdminFieldHelp :text="priceTriggersPageIntro" />
<BaseButton type="primary" size="small" @click="emits('add')">
<span>افزودن هشدار</span>
<BaseIcon icon="vuesax-linear:add" size="16" />
</BaseButton>
</div>
<BaseButton type="primary" size="small" @click="emits('add')">
<span>افزودن هشدار</span>
<BaseIcon icon="vuesax-linear:add" size="16" />
</BaseButton>
</template>
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
@@ -54,7 +51,6 @@ import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/priceTriggersColumns'
import { getPriceTriggersTable } from '@/api/priceTriggers'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { priceTriggersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
@@ -1,10 +1,8 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="تاریخچه قیمت">
<BasicShowContext title="تاریخچه قیمت" :help="pricingPageIntro">
<template #extra>
<div class="flex items-center gap-2">
<AdminFieldHelp :text="pricingPageIntro" />
<BaseButton
<BaseButton
v-if="isMobile"
type="primary"
size="small"
@@ -14,7 +12,6 @@
>
به‌روزرسانی قیمت
</BaseButton>
</div>
</template>
<div class="flex h-full min-h-0 flex-col">
@@ -63,7 +60,6 @@ import { storePriceColumns } from '../hooks/storePriceColumns'
import { usePrefix } from '@core/composable/usePrefix'
import { getPriceSyncLogTable } from '@/api/price'
import PriceMobileCardList from './PriceMobileCardList.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { pricingPageIntro } from '@/utils/adminHelpCopy'
import { isMobile } from '@core/utils/is'
import type { PriceSyncLog } from '@/model/priceSyncLog'
@@ -36,7 +36,7 @@
</div>
</div>
<BasicShowContext title="لیست محصولات" class="min-h-0 flex-1">
<BasicShowContext title="لیست محصولات" class="min-h-0 flex-1" :help="productsPageIntro">
<template #extra>
<BaseButton
v-if="isMobile"
@@ -148,6 +148,7 @@ import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import type { Product } from '@/model/product'
import { isMobile } from '@core/utils/is'
import { productsPageIntro } from '@/utils/adminHelpCopy'
dayjs.extend(customParseFormat)
@@ -1,6 +1,10 @@
<template>
<div class="category-page flex h-full min-h-0 flex-col gap-4 p-4">
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 text-sm font-600" style="color: var(--admin-text)">
دسته‌بندی محصولات
<AdminFieldHelp :text="productsCategoriesPageIntro" size="sm" />
</span>
<BaseButton :loading="syncing" @click="handleSync">
همگام‌سازی از محصولات
</BaseButton>
@@ -65,6 +69,8 @@ import { debounce } from 'lodash'
import { onMounted, onUnmounted, ref } from 'vue'
import { getCategories, syncCategoriesFromProducts, updateCategory } from '@/api/category'
import type { Category } from '@/model/category'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { productsCategoriesPageIntro } from '@/utils/adminHelpCopy'
const loading = ref(false)
const syncing = ref(false)
+2 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست پیامک ها">
<BasicShowContext title="لیست پیامک ها" :help="smsPageIntro">
<template v-if="groupSmsEnabled && !mobile && isMobileScreen" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:sms" @click="emits('add')">
ارسال پیامک
@@ -40,6 +40,7 @@ import { getSMSTable } from '@/api/sms'
import SmsMobileCardList from './SmsMobileCardList.vue'
import { isMobile as isMobileScreen } from '@core/utils/is'
import { groupSmsEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
import { smsPageIntro } from '@/utils/adminHelpCopy'
interface Prop {
mobile?: number | string
+6 -1
View File
@@ -2,7 +2,10 @@
<div class="sms-panel-page h-full min-h-0 w-full min-w-0">
<div class="sms-panel-page__scroll h-full min-h-0 overflow-y-auto overflow-x-hidden px-2 pb-8 md:px-4">
<header class="mb-6 md:mb-8">
<h1 class="sms-panel-heading text-xl font-bold md:text-2xl">پنل پیامک</h1>
<h1 class="sms-panel-heading inline-flex items-center gap-1.5 text-xl font-bold md:text-2xl">
پنل پیامک
<AdminFieldHelp :text="smsPanelPageIntro" size="sm" />
</h1>
<p class="sms-panel-subtitle mt-1 text-sm">مدیریت کیف پول، تعرفه و گزارش ارسال پیامک</p>
</header>
@@ -628,6 +631,8 @@ import BasicModal from '@/components/BasicModal.vue'
import BaseDatePicker from '@core/components/BaseDatePicker'
import BaseButton from '@core/components/BaseButton'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { smsPanelPageIntro } from '@/utils/adminHelpCopy'
import SmsPanelLogsTable from './components/SmsPanelLogsTable.vue'
import SmsPanelLedgerTable from './components/SmsPanelLedgerTable.vue'
import {
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست گزارشات">
<BasicShowContext title="لیست گزارشات" :help="transactionsPageIntro">
<div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<!-- <template #extraHeader>
@@ -75,6 +75,7 @@ import {getWalletTable} from '@/api/wallet'
import { getTransactionTable } from '@/api/transaction'
import TransactionMobileCardList from './TransactionMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { transactionsPageIntro } from '@/utils/adminHelpCopy'
interface Prop {
customerId?: number
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست مدیران">
<BasicShowContext title="لیست مدیران" :help="usersPageIntro">
<template #extra>
<BaseButton
v-if="isMobile"
@@ -65,6 +65,7 @@ import { getUserTable } from '@/api/user'
import { cols } from '../hooks/usersColumns'
import ManagerMobileCardList from './ManagerMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { usersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0">
<BasicShowContext title="لیست کیف پول">
<BasicShowContext title="لیست کیف پول" :help="walletPageIntro">
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="hidden shrink-0 md:grid md:grid-cols-1 md:gap-3 lg:grid-cols-2 xl:grid-cols-3">
<div
@@ -81,6 +81,7 @@ import { isMobile } from '@core/utils/is'
import { totalMetalWalletTitle } from '@/utils/adminMetalLabels'
import type { WalletTableSums } from '@/model/wallet'
import { formatMetalDisplayValue, metalDisplayUnit } from '@/utils/metalWeight'
import { walletPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,6 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست برداشت و واریز">
<BasicShowContext title="لیست برداشت و واریز" :help="withdrawPageIntro">
<div class="flex h-full min-h-0 flex-col">
<BaseTable v-if="!isMobile" :key="flowType" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
@@ -241,6 +241,7 @@ onMounted(() => {
import { status } from '@/pages/menuPages/withdraw/information/hooks/status'
import {toNumber} from "lodash";
import { withdrawPageIntro } from '@/utils/adminHelpCopy'
const withdrawId = ref(null)
const sts = ref()
const statusList = computed(() => {
+6 -2
View File
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { importFromTahesab, type TahesabOnlyCustomer } from '@/api/tahesab'
import { importFromPargar, type PargarOnlyCustomer } from '@/api/pargar'
import { importFromZarin, type ZarinOnlyCustomer } from '@/api/zarin'
export interface ImportError {
code: number
@@ -10,12 +11,12 @@ export interface ImportError {
}
export type ImportStatus = 'idle' | 'running' | 'done' | 'canceled'
export type ImportProvider = 'tahesab' | 'pargar'
export type ImportProvider = 'tahesab' | 'pargar' | 'zarin'
/** نتیجهٔ نهایی برای نمایش آیکن/متن مناسب */
export type ImportOutcome = 'running' | 'success' | 'error' | 'partial' | 'canceled'
type ImportCustomer = TahesabOnlyCustomer | PargarOnlyCustomer
type ImportCustomer = TahesabOnlyCustomer | PargarOnlyCustomer | ZarinOnlyCustomer
/** تعداد مشتری در هر درخواست (برای جلوگیری از محدودیت نرخ سرور) */
const CHUNK_SIZE = 50
@@ -120,6 +121,9 @@ export const useCustomerImportStore = defineStore('customerImport', () => {
if (provider.value === 'pargar') {
return await importFromPargar(chunk as PargarOnlyCustomer[])
}
if (provider.value === 'zarin') {
return await importFromZarin(chunk as ZarinOnlyCustomer[])
}
return await importFromTahesab(chunk as TahesabOnlyCustomer[])
} catch (e) {
if (attempt < 2 && !cancelRequested) {
+117
View File
@@ -170,3 +170,120 @@ export const automationStatusHelp =
export const tradingHoursPageIntro =
'ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری. می‌توانید برای همه روزها یک ساعت بگذارید یا برای هر روز هفته جداگانه تنظیم کنید.'
export const customersPageIntro =
'لیست مشتریان ثبت‌شده در اپ. از اینجا می‌توانید مشتری جدید بسازید، ویرایش کنید، موجودی و سفارش‌ها را ببینید، یا مشتری را بایگانی کنید.'
export const customersArchivePageIntro =
'مشتریانی که بایگانی شده‌اند اینجا هستند؛ در اپ فعال نیستند. می‌توانید دوباره از بایگانی خارج‌شان کنید.'
export const customersOnlinePageIntro =
'مشتریانی که الان اپ را باز دارند. با بستن اپ از لیست حذف می‌شوند. برای جزئیات روی کارت بزنید.'
export const goldCreditPageIntro =
'اعتبار طلایی مشتریان: درخواست‌های جدید و اعتبارهای فعال. از تب‌ها بین درخواست و فعال جابه‌جا شوید.'
export const goldCreditInstallmentsIntro =
'اقساط مربوط به اعتبار طلایی همین مشتری. وضعیت پرداخت هر قسط را اینجا می‌بینید.'
export const specialPricePageIntro =
'پکیج قیمت ویژه برای گروهی از مشتریان. با پکیج می‌توانید خرید/فروش را با قیمت یا کارمزد متفاوت از بقیه تنظیم کنید.'
export const productsPageIntro =
'محصولات قابل معامله در اپ. افزودن، ویرایش، قیمت‌گذاری، موجودی و بایگانی از همین‌جا انجام می‌شود.'
export const productsCategoriesPageIntro =
'دسته‌بندی محصولات برای فیلتر تب‌های اپ. ترتیب را با کشیدن عوض کنید؛ «همگام‌سازی از محصولات» دسته‌های گم‌شده را از روی محصولات می‌سازد.'
export const ordersPageIntro =
'سفارش‌های خرید و فروش مشتریان. می‌توانید سفارش جدید ثبت کنید یا جزئیات و وضعیت هر سفارش را ببینید.'
export const conditionalOrdersPageIntro =
'سفارش شرطی: وقتی قیمت به هدف برسد سیستم خودکار سفارش می‌زند. فعال/غیرفعال و ویرایش از همین لیست.'
export const colleagueOrdersPageIntro =
'تراکنش‌های پوشش‌شده با اپ همکار. برای ثبت درست سند حسابداری، کد حساب همکار را هم در همین صفحه تنظیم کنید.'
export const colleagueAccountingCodeHelp =
'کدی که سند خرید/فروش پوشش‌شدهٔ همکار روی آن در سیستم حسابداری فعال ثبت می‌شود. فقط فیلد مربوط به حسابداری فعال را پر کنید.'
export const transactionsPageIntro =
'گزارش تراکنش‌های کیف پول و عملیات مالی مشتریان. برای بررسی جزئیات هر ردیف را باز کنید.'
export const walletPageIntro =
'موجودی کیف‌های مشتریان (طلا، ریال، محصول، قلک). خلاصه و جزئیات هر کیف را اینجا می‌بینید.'
export const withdrawPageIntro =
'درخواست‌های واریز و برداشت ریالی مشتریان. تایید، رد یا تعیین وزن (در صورت نیاز) از همین لیست.'
export const cardsPageIntro =
'حساب‌ها و کارت‌های بانکی ثبت‌شده برای واریز/برداشت. افزودن، ویرایش یا حذف از همین‌جا.'
export const giftCardsPageIntro =
'ساخت و مدیریت کارت هدیه (ریالی یا طلا). هر پویش چند کد یکتا می‌سازد؛ وضعیت مصرف و انقضا را اینجا ببینید.'
export const discountPageIntro =
'پویش‌های کد تخفیف. درصد، تعداد کد و تاریخ انقضا را تنظیم کنید؛ هر کد معمولاً یک‌بار مصرف است.'
export const smsPageIntro =
'تاریخچه پیامک‌های ارسال‌شده به مشتریان. در صورت فعال بودن پیامک گروهی، از همین‌جا هم می‌توانید پیامک بفرستید.'
export const smsPanelPageIntro =
'پنل شارژ و وضعیت سرویس پیامک. موجودی، خرید بسته و تنظیمات ارسال پیامک در این صفحه است.'
export const appNotificationsPageIntro =
'اعلان ادمین را دستی می‌فرستید؛ اعلان سیستمی خودکار برای خرید/فروش/واریز/برداشت ساخته می‌شود. از تب‌ها برای جدا کردن لیست استفاده کنید.'
export const automationsPageIntro =
'قوانین پیام خودکار (زمان‌دار، رویداد یا قیمت). مخاطب، متن، کانال پیامک/اعلان و سقف ارسال را اینجا تنظیم کنید.'
export const usersPageIntro =
'مدیران پنل ادمین. افزودن مدیر، ویرایش دسترسی و غیرفعال کردن از همین لیست.'
export const captureSitesPageIntro =
'سایت‌های منبع قیمت برای ری‌گیری. روشن/خاموش کردن ری‌گیری و مدیریت آدرس‌ها از همین صفحه.'
export const configurationPageIntro =
'تنظیمات کلی سامانه (کلید–مقدار). با جستجو و فیلتر نوع فیلد، مورد را پیدا کنید؛ بعد از تغییر ذخیره کنید. روی آیکون i کنار هر تنظیم توضیح همان فیلد را ببینید.'
export const accountingReportsPageIntro =
'گزارش‌های حسابداری داخلی پنل. برای اسناد و همگام‌سازی مشتریان از تب‌های ته‌حساب / زرین / پرگار استفاده کنید.'
export const accountingTahesabDocsIntro =
'اسناد ارسال‌شده یا در صف ته‌حساب. می‌توانید وضعیت، خطا و جزئیات هر سند را ببینید و در صورت نیاز همگام کنید.'
export const accountingZarinDocsIntro =
'اسناد ارسال‌شده یا در صف زرین. وضعیت، خطا و جزئیات هر سند را اینجا پیگیری کنید.'
export const accountingPargarDocsIntro =
'اسناد پرگار. وضعیت ارسال، خطا و جزئیات هر سند را اینجا ببینید.'
export const accountingTahesabCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد ته‌حساب. تب «پنل ← ته‌حساب» کسانی که فقط در پنل‌اند؛ تب برعکس کسانی که فقط در ته‌حساب‌اند.'
export const accountingZarinCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد زرین. مشتریان بدون لینک را از دو طرف پیدا و ثبت کنید.'
export const accountingPargarCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد پرگار. مشتریان بدون لینک را از دو طرف پیدا و ثبت کنید.'
export const accountingTahesabBalanceSyncIntro =
'جدول مشتریان لینک‌شده ماندهٔ کیف را کنار ماندهٔ ته‌حساب و سندها نشان می‌دهد. همگام تکی اول بررسی و تأیید است. «همگام‌سازی» همه فقط کیف خالی را می‌نشاند و تداخل را نگه می‌دارد؛ «اعمال اجباری» مانده ته‌حساب را برای همه جایگزین می‌کند. دو مانده جمع نمی‌شوند.'
export const accountingZarinBalanceSyncIntro =
'مانده طلا و ریال زرین را روی کیف اصلی مشتریان لینک‌شده می‌نشاند. همگام‌سازی دستی است؛ تداخل برای تصمیم شما می‌ماند. دو مانده با هم جمع نمی‌شوند.'
export const accountingPargarBalanceSyncIntro =
'مانده پرگار را روی کیف مشتریان لینک‌شده می‌نشاند. قبل از اجرا مطمئن شوید کد پرگار مشتریان درست است.'
export const customerFundsIntro =
'دارایی‌های مرتبط با همین مشتری (محصول و موجودی). جزئیات و تراکنش هر دارایی را از اکشن‌ها باز کنید.'
export const customerPiggyFundsIntro =
'قلک‌های همین مشتری و موجودی هرکدام.'
export const customerReferralsIntro =
'مشتریانی که با کد معرف همین نفر ثبت‌نام کرده‌اند.'
export const customerPargarLogsIntro =
'لاگ و اسناد پرگار مربوط به همین مشتری.'
+7
View File
@@ -22,6 +22,13 @@ export function hasAppUiConfig(): boolean {
return Boolean(cfg)
}
/** true اگر get_user → ui_variant برابر modular باشد (بنرهای تم اپ) */
export function isModularUiVariant(): boolean {
return String(getConfigApp('ui_variant') ?? '')
.trim()
.toLowerCase() === 'modular'
}
let storeModeFromSettings: boolean | null = null
let settingsBootstrapped = false