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:
Vendored
-16
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
'لاگ و اسناد پرگار مربوط به همین مشتری.'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user