Require review before Tahesab wallet sync and harden accounting admin UX.

Single-customer and row sync preview balances with documents before overwrite/keep; bulk sync keeps confirm. Also ship Zarin wallet sync actions, field help, and get_user cache improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 21:18:26 +03:30
co-authored by Cursor
parent 04cc03fe25
commit 1c4b2d4435
60 changed files with 2464 additions and 552 deletions
-16
View File
@@ -11,7 +11,6 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default'] _index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert'] AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge'] ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card'] 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'] AdminHelpTooltipContent: typeof import('./src/components/AdminHelpTooltipContent.vue')['default']
AdminLabelWithHelp: typeof import('./src/components/AdminLabelWithHelp.vue')['default'] AdminLabelWithHelp: typeof import('./src/components/AdminLabelWithHelp.vue')['default']
ADrawer: typeof import('ant-design-vue/es')['Drawer'] ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty'] AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input'] 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'] ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] 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'] AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] 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'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption'] ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch'] ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table'] ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane'] ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs'] ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] 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'] BaseButton: typeof import('./src/core/components/BaseButton/index.ts')['default']
BaseCard: typeof import('./src/core/components/BaseCard/index.ts')['default'] BaseCard: typeof import('./src/core/components/BaseCard/index.ts')['default']
BaseCollapse: typeof import('./src/components/BaseCollapse.vue')['default'] BaseCollapse: typeof import('./src/components/BaseCollapse.vue')['default']
+97 -1
View File
@@ -89,6 +89,19 @@ export interface TahesabOnlyCustomer {
tel: string tel: string
gid: number | null gid: number | null
group: string 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 { export interface CustomerSyncStatus {
@@ -97,6 +110,13 @@ export interface CustomerSyncStatus {
panel_only: number panel_only: number
tahesab_total: number tahesab_total: number
tahesab_only_count: 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: { panel_only_list: {
data: PanelOnlyCustomer[] data: PanelOnlyCustomer[]
current_page: number current_page: number
@@ -115,6 +135,7 @@ export const getCustomerSyncStatus = async (params?: {
search?: string search?: string
page?: number page?: number
tahesab_page?: number tahesab_page?: number
include_tahesab?: number | boolean
}) => await useAxios.get<CustomerSyncStatus>('/admin/tahesab/customer-sync/status', { params }) }) => await useAxios.get<CustomerSyncStatus>('/admin/tahesab/customer-sync/status', { params })
export interface SyncPanelResult { export interface SyncPanelResult {
@@ -132,6 +153,33 @@ export const importFromTahesab = async (customers: TahesabOnlyCustomer[]) =>
customers, 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 { export interface TahesabWalletSyncResult {
ok: boolean ok: boolean
needs_decision?: boolean needs_decision?: boolean
@@ -146,6 +194,10 @@ export interface TahesabWalletSyncResult {
app_cash?: number app_cash?: number
tahesab_gold?: number tahesab_gold?: number
tahesab_cash?: number tahesab_cash?: number
zarin_gold?: number
zarin_cash?: number
wallet_docs?: TahesabPreviewDoc[]
tahesab_docs?: TahesabPreviewDoc[]
message: string message: string
} }
} }
@@ -153,7 +205,7 @@ export interface TahesabWalletSyncResult {
/** همگام‌سازی مانده یک مشتری با ته‌حساب */ /** همگام‌سازی مانده یک مشتری با ته‌حساب */
export const syncCustomerWalletFromTahesab = async ( export const syncCustomerWalletFromTahesab = async (
customerId: number, 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>( await useAxios.post<TahesabWalletSyncResult>(
`/admin/customer/${customerId}/tahesab/sync-wallet`, `/admin/customer/${customerId}/tahesab/sync-wallet`,
@@ -161,9 +213,16 @@ export const syncCustomerWalletFromTahesab = async (
overwrite: opts?.overwrite ?? false, overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false, keep: opts?.keep ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true, 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 { export interface TahesabBulkWalletSyncChunk {
ok: boolean ok: boolean
message: string message: string
@@ -200,3 +259,40 @@ export const syncAllWalletsFromTahesabChunk = async (params?: {
limit: params?.limit ?? 20, limit: params?.limit ?? 20,
overwrite: params?.overwrite ?? false, overwrite: params?.overwrite ?? false,
}) })
export interface TahesabWalletLinkedRow {
id: number
name: string
mobile: string
moshtari_code: number | null
app_gold: number
app_cash: number
tahesab_gold: number | null
tahesab_cash: number | null
gold_delta?: number | null
rial_delta?: number | null
status: 'same' | 'conflict' | 'ready' | 'missing'
message: string
}
export interface TahesabWalletLinkedList {
ok: boolean
total: number
page: number
per_page: number
last_page: number
rows: TahesabWalletLinkedRow[]
}
export const getTahesabWalletLinkedList = async (params?: {
search?: string
page?: number
per_page?: number
}) =>
await useAxios.get<TahesabWalletLinkedList>('/admin/tahesab/wallet-sync/list', {
params: {
search: params?.search || undefined,
page: params?.page ?? 1,
per_page: params?.per_page ?? 20,
},
})
+106
View File
@@ -89,6 +89,19 @@ export interface ZarinOnlyCustomer {
tel: string tel: string
gid: number | null gid: number | null
group: string 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 { export interface CustomerSyncStatus {
@@ -97,6 +110,13 @@ export interface CustomerSyncStatus {
panel_only: number panel_only: number
zarin_total: number | null zarin_total: number | null
zarin_only_count: 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_fetch_error?: string | null
zarin_http_status?: number | 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', { await useAxios.post<SyncPanelResult>('/admin/zarin/customer-sync/import-from-zarin', {
customers, customers,
}) })
export const linkZarinMatchingPhones = async () =>
await useAxios.post<{
ok: boolean
linked: number
conflicts: number
message: string
linked_rows: AccountingPhoneMatch[]
conflict_rows: AccountingPhoneMatch[]
}>('/admin/zarin/customer-sync/link-by-phone')
export interface ZarinWalletSyncResult {
ok: boolean
needs_decision?: boolean
message: string
result?: {
applied: boolean
needs_decision?: boolean
skipped?: boolean
gold_delta?: number
rial_delta?: number
app_gold?: number
app_cash?: number
zarin_gold?: number
zarin_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}
}
export const syncCustomerWalletFromZarin = async (
customerId: number,
opts?: { overwrite?: boolean; keep?: boolean; ask_if_conflict?: boolean; always_ask?: boolean }
) =>
await useAxios.post<ZarinWalletSyncResult>(
`/admin/customer/${customerId}/zarin/sync-wallet`,
{
overwrite: opts?.overwrite ?? false,
keep: opts?.keep ?? false,
ask_if_conflict: opts?.ask_if_conflict ?? true,
always_ask: opts?.always_ask ?? false,
}
)
export const previewCustomerWalletFromZarin = async (customerId: number, code?: string | number) =>
await useAxios.post<ZarinWalletSyncResult>(
`/admin/customer/${customerId}/zarin/preview-wallet`,
{ code },
)
export interface ZarinBulkWalletSyncChunk {
ok: boolean
message: string
total: number
offset: number
processed: number
next_offset: number
done: boolean
success: number
changed: number
needs_decision: number
failed: number
errors: { id: number; name: string; mobile?: string; error: string }[]
decisions: {
id: number
name: string
mobile?: string
app_gold?: number
app_cash?: number
tahesab_gold?: number
tahesab_cash?: number
message: string
}[]
}
export const syncAllWalletsFromZarinChunk = async (params?: {
offset?: number
limit?: number
overwrite?: boolean
}) =>
await useAxios.post<ZarinBulkWalletSyncChunk>('/admin/zarin/wallet-sync/all', {
offset: params?.offset ?? 0,
limit: params?.limit ?? 20,
overwrite: params?.overwrite ?? false,
})
+10 -2
View File
@@ -1,7 +1,10 @@
<template> <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="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="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" /> <slot name="extra" />
</div> </div>
<div class="mt-2 min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-1"> <div class="mt-2 min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-1">
@@ -11,8 +14,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
interface Prop { interface Prop {
title: string title: string
help?: string
} }
const props = defineProps<Prop>() const props = withDefaults(defineProps<Prop>(), {
help: '',
})
</script> </script>
+149 -25
View File
@@ -14,6 +14,9 @@
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'; const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL';
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'; const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL';
const CONFIG_CACHE_KEY = 'APP_CONFIG_CACHE'; 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_STORAGE_KEY = 'version';
const VERSION_UPDATE_LOCK_KEY = 'version_update_lock'; const VERSION_UPDATE_LOCK_KEY = 'version_update_lock';
const VERSION_LAST_RELOAD_KEY = 'version_last_reload'; const VERSION_LAST_RELOAD_KEY = 'version_last_reload';
@@ -130,13 +133,13 @@
return readUserVersion(response.user); return readUserVersion(response.user);
} }
function readCachedAppConfig() { function readCachedAppConfigPayload() {
try { try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY); const raw = localStorage.getItem(CONFIG_CACHE_KEY);
if (!raw) return null; if (!raw) return null;
const payload = JSON.parse(raw); const payload = JSON.parse(raw);
if (!payload || typeof payload !== 'object' || !payload.config) return null; if (!payload || typeof payload !== 'object' || !payload.config) return null;
return payload.config; return payload;
} catch (error) { } catch (error) {
console.warn('Failed to read cached get_user:', error); console.warn('Failed to read cached get_user:', error);
localStorage.removeItem(CONFIG_CACHE_KEY); localStorage.removeItem(CONFIG_CACHE_KEY);
@@ -144,18 +147,125 @@
} }
} }
function writeCachedAppConfig(config) { function readCachedAppConfig() {
try { const payload = readCachedAppConfigPayload();
localStorage.setItem( return payload && payload.config ? payload.config : null;
CONFIG_CACHE_KEY, }
JSON.stringify({
cached_at: Date.now(), function isFreshGetUserCache(payload, remoteVersion, domain) {
config: config if (!payload || !payload.config) return false;
}) if (isServerUnreachable(payload.config)) return false;
); const cachedDomain = String(payload.domain || '').trim();
} catch (error) { if (cachedDomain && cachedDomain !== domain) return false;
console.warn('Failed to write get_user cache:', error); 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() { function getStoredAppVersion() {
@@ -238,6 +348,7 @@
localStorage.removeItem(CONFIG_CACHE_KEY); localStorage.removeItem(CONFIG_CACHE_KEY);
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY); localStorage.removeItem(CONFIG_SUCCESS_URL_KEY);
localStorage.removeItem(VERSION_SUCCESS_URL_KEY); localStorage.removeItem(VERSION_SUCCESS_URL_KEY);
clearCachedAppConfigIdb();
restoreAuthToken(token); restoreAuthToken(token);
} }
@@ -311,7 +422,7 @@
return null; return null;
} }
async function fetchConfig(rounds) { async function fetchConfig(rounds, version) {
rounds = rounds || CONFIG_RETRY_ROUNDS; rounds = rounds || CONFIG_RETRY_ROUNDS;
const urls = sortUrls(getConfigUrls(), CONFIG_SUCCESS_URL_KEY); const urls = sortUrls(getConfigUrls(), CONFIG_SUCCESS_URL_KEY);
@@ -328,7 +439,7 @@
} }
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url); localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url);
writeCachedAppConfig(config); writeCachedAppConfig(config, version);
return { config: config, url: url }; return { config: config, url: url };
} catch (error) { } catch (error) {
clearStaleSuccessUrl(CONFIG_SUCCESS_URL_KEY, url); clearStaleSuccessUrl(CONFIG_SUCCESS_URL_KEY, url);
@@ -413,15 +524,15 @@
startVersionUpdateLock(serverVersion); startVersionUpdateLock(serverVersion);
try { try {
const { config } = await fetchConfig(); const { config } = await fetchConfig(undefined, serverVersion);
if (isServerUnreachable(config)) { if (isServerUnreachable(config)) {
writeCachedAppConfig(config); writeCachedAppConfig(config, serverVersion);
showServerUnavailable(); showServerUnavailable();
cancelVersionUpdateLock(); cancelVersionUpdateLock();
return true; return true;
} }
clearBootstrapCachesKeepToken(); clearBootstrapCachesKeepToken();
writeCachedAppConfig(config); writeCachedAppConfig(config, serverVersion);
applyConfig(config); applyConfig(config);
finishVersionUpdateBeforeReload(serverVersion); finishVersionUpdateBeforeReload(serverVersion);
console.log('App version changed — hard refresh', serverVersion); console.log('App version changed — hard refresh', serverVersion);
@@ -441,8 +552,8 @@
let needsFreshForVersion = false; let needsFreshForVersion = false;
try { try {
// کش قدیمی را فقط اگر خودش قفل باشد اعمال/قطع کن؛ وگرنه تا get_user تازه صبر کن const cachedPayload = await resolveCachedPayload();
const cachedConfig = readCachedAppConfig(); const cachedConfig = cachedPayload && cachedPayload.config ? cachedPayload.config : null;
if (cachedConfig && isServerUnreachable(cachedConfig)) { if (cachedConfig && isServerUnreachable(cachedConfig)) {
showServerUnavailable(); showServerUnavailable();
return; 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); applyConfig(config);
writeCachedAppConfig(config); writeCachedAppConfig(config, remoteVersion);
if (remoteVersion) { if (remoteVersion) {
writeStoredAppVersion(remoteVersion); writeStoredAppVersion(remoteVersion);
} }
@@ -477,13 +602,13 @@
console.warn('Config loader: Could not fetch config from any endpoint'); console.warn('Config loader: Could not fetch config from any endpoint');
console.warn('Error details:', error && error.message ? error.message : error); console.warn('Error details:', error && error.message ? error.message : error);
// اگر نسخه عوض شده (قفل/آنلاک)، هرگز با کش قدیمی reachable ادمین را بالا نیاور
if (versionChanged || needsFreshForVersion) { if (versionChanged || needsFreshForVersion) {
showServerUnavailable(); showServerUnavailable();
return; return;
} }
const cachedConfig = readCachedAppConfig(); const cachedPayload = await resolveCachedPayload();
const cachedConfig = cachedPayload && cachedPayload.config ? cachedPayload.config : null;
if (cachedConfig) { if (cachedConfig) {
try { try {
applyConfig(cachedConfig); applyConfig(cachedConfig);
@@ -519,7 +644,6 @@
showServerUnavailable(); showServerUnavailable();
return; return;
} }
// بدون تأیید تازه از get_user، ادمین را با کش بالا نیاور (دور زدن قفل)
showServerUnavailable(); showServerUnavailable();
} }
}, CONFIG_TIMEOUT); }, CONFIG_TIMEOUT);
@@ -1,13 +1,14 @@
<template> <template>
<div class="cs-wrap"> <div class="cs-wrap">
<PargarWalletBulkSync />
<ColleagueAccountingAccounts provider-only="pargar" /> <ColleagueAccountingAccounts provider-only="pargar" />
<!-- ─── بنر آمار ─── --> <!-- ─── بنر آمار ─── -->
<div class="cs-banner"> <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"> <div class="cs-stats-main">
@@ -327,8 +328,9 @@ import {
type SyncPanelResult, type SyncPanelResult,
} from '@/api/pargar' } from '@/api/pargar'
import { useCustomerImportStore } from '@/store/customerImportStore' import { useCustomerImportStore } from '@/store/customerImportStore'
import PargarWalletBulkSync from './PargarWalletBulkSync.vue'
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue' import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
import { accountingPargarCustomerSyncIntro } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
const importStore = useCustomerImportStore() const importStore = useCustomerImportStore()
@@ -580,6 +582,7 @@ onMounted(() => {
@media (min-width: 640px) { padding: 20px 24px; gap: 16px; } @media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
&__title { font-size: 14px; font-weight: 700; } &__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)" /> <Icon icon="vuesax-linear:refresh-circle" :size="20" color="var(--admin-primary, #10913A)" />
</div> </div>
<div class="pw-bulk__copy min-w-0 flex-1"> <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"> <p class="pw-bulk__hint">
موجودی کیف همهٔ مشتریان دارای کد پرگار را با ماندهٔ پرگار یکی می‌کند — تاریخچهٔ اسناد روی کیف موجودی کیف همهٔ مشتریان دارای کد پرگار را با ماندهٔ پرگار یکی می‌کند — تاریخچهٔ اسناد روی کیف
replay نمی‌شود replay نمی‌شود
@@ -64,6 +67,8 @@ import { computed, createVNode, ref } from 'vue'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue' import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue' import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { accountingPargarBalanceSyncIntro } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { import {
syncAllWalletsFromPargarChunk, syncAllWalletsFromPargarChunk,
type PargarBulkWalletSyncChunk, type PargarBulkWalletSyncChunk,
@@ -184,6 +189,10 @@ async function start() {
color: var(--admin-text, inherit); color: var(--admin-text, inherit);
} }
.pw-bulk__title-row {
@apply flex items-center gap-1.5;
}
.pw-bulk__hint { .pw-bulk__hint {
@apply m-0 mt-0.5 text-xs leading-5; @apply m-0 mt-0.5 text-xs leading-5;
color: var(--admin-text-secondary, #6b7280); color: var(--admin-text-secondary, #6b7280);
@@ -1,64 +1,65 @@
<template> <template>
<div class="cs-wrap"> <div class="cs-wrap">
<TahesabWalletBulkSync />
<ColleagueAccountingAccounts provider-only="tahesab" /> <ColleagueAccountingAccounts provider-only="tahesab" />
<!-- ─── بنر آمار ─── --> <!-- ─── آمار فشرده ─── -->
<div class="cs-banner"> <div class="cs-banner">
<div class="cs-banner__title">همگام‌سازی مشتریان</div> <div class="cs-banner__top">
<span class="cs-banner__title-row">
<!-- آمار اصلی: ردیف بالا --> <span class="cs-banner__title">همگام‌سازی مشتریان</span>
<div class="cs-stats-main"> <AdminFieldHelp :text="accountingTahesabCustomerSyncIntro" size="sm" />
<div class="cs-stat cs-stat--neutral"> </span>
<div class="cs-stat__num"> <span v-if="status" class="cs-banner__meta">
<template v-if="status">{{ status.panel_total }}</template> {{ registeredPercent }}٪ لینک‌شده
<a-spin v-else size="small" /> · {{ status.panel_registered }} / {{ status.panel_total }}
</div> </span>
<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> </div>
<!-- آمار تفاوت: ردیف پایین --> <div class="cs-stats">
<div class="cs-stats-diff"> <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 cs-stat--amber">
<div class="cs-stat__num"> <span class="cs-stat__num">
<template v-if="status">{{ status.panel_only }}</template> <template v-if="status">{{ status.panel_only }}</template>
<a-spin v-else-if="loadingPanel" size="small" /> <a-spin v-else-if="loadingPanel" size="small" />
<span v-else>—</span> <span v-else>—</span>
</div> </span>
<div class="cs-stat__label">پنل — بدون ته‌حساب</div> <span class="cs-stat__label">فقط پنل</span>
</div> </div>
<div class="cs-stat cs-stat--sky"> <div class="cs-stat">
<div class="cs-stat__num"> <span class="cs-stat__num">
<template v-if="status?.tahesab_only_count != null">{{ status.tahesab_only_count }}</template> <template v-if="status?.tahesab_importable_count != null">{{ status.tahesab_importable_count }}</template>
<a-spin v-else-if="loadingTahesab" size="small" /> <a-spin v-else-if="loadingTahesab" size="small" />
<span v-else class="cs-stat__hint">تب دوم</span> <span v-else class="cs-stat__hint">—</span>
</div> </span>
<div class="cs-stat__label">ته‌حساب — بدون پنل</div> <span class="cs-stat__label">قابل وارد کردن</span>
</div> </div>
</div> </div>
<!-- progress --> <div v-if="status" class="cs-track">
<div v-if="status" class="cs-progress"> <div class="cs-track__fill" :style="{ width: registeredPercent + '%' }" />
<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> </div>
</div> </div>
@@ -69,8 +70,7 @@
:class="{ 'cs-tab--active': activeTab === 'panel' }" :class="{ 'cs-tab--active': activeTab === 'panel' }"
@click="onTabChange('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> <span v-if="status?.panel_only" class="cs-tab__badge cs-tab__badge--amber">{{ status.panel_only }}</span>
</button> </button>
<button <button
@@ -78,9 +78,8 @@
:class="{ 'cs-tab--active': activeTab === 'tahesab' }" :class="{ 'cs-tab--active': activeTab === 'tahesab' }"
@click="onTabChange('tahesab')" @click="onTabChange('tahesab')"
> >
<Icon icon="vuesax-linear:arrow-right" :size="14" /> از ته‌حساب به پنل
ته‌حساب ← پنل <span v-if="status?.tahesab_importable_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.tahesab_importable_count }}</span>
<span v-if="status?.tahesab_only_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.tahesab_only_count }}</span>
</button> </button>
</div> </div>
@@ -100,16 +99,18 @@
class="cs-search" class="cs-search"
@change="onPanelSearch" @change="onPanelSearch"
/> />
<a-button <div class="cs-card__head-btns">
type="primary" <a-button
size="small" type="primary"
:loading="syncingPanel" size="small"
:disabled="!status?.panel_only" :loading="syncingPanel"
@click="syncAllPanel" :disabled="!status?.panel_only"
> @click="syncAllPanel"
<template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template> >
ثبت همه در ته‌حساب <template #icon><Icon icon="vuesax-linear:send-2" :size="13" /></template>
</a-button> ثبت همه در ته‌حساب
</a-button>
</div>
</div> </div>
</div> </div>
@@ -191,6 +192,28 @@
<!-- ─── تب ۲: ته‌حساب → پنل ─── --> <!-- ─── تب ۲: ته‌حساب → پنل ─── -->
<div v-show="activeTab === 'tahesab'" class="cs-card"> <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">
<div class="cs-card__head-right"> <div class="cs-card__head-right">
<span class="cs-dot cs-dot--sky" /> <span class="cs-dot cs-dot--sky" />
@@ -205,24 +228,26 @@
class="cs-search" class="cs-search"
@change="onTahesabSearch" @change="onTahesabSearch"
/> />
<a-button <div class="cs-card__head-btns">
type="primary" <a-button
size="small" type="primary"
:loading="syncingTahesab" size="small"
:disabled="!status?.tahesab_only_count || selectedTahesab.size === 0" :loading="syncingTahesab"
@click="importSelected" :disabled="importableSelectedCount === 0"
> @click="importSelected"
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template> >
وارد کردن انتخاب‌شده‌ها ({{ selectedTahesab.size }}) <template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
</a-button> وارد کردن انتخاب‌شده ({{ importableSelectedCount }})
<a-button </a-button>
size="small" <a-button
:loading="importStore.active" size="small"
:disabled="!filteredTahesabList.length" :loading="importStore.active"
@click="importAll" :disabled="!importableCount"
> @click="importAll"
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${status?.tahesab_only_count ?? 0})` }} >
</a-button> {{ importStore.active ? 'در حال…' : `وارد کردن همه (${importableCount})` }}
</a-button>
</div>
</div> </div>
</div> </div>
@@ -251,21 +276,24 @@
<div <div
v-for="c in pagedTahesab" :key="c.code" v-for="c in pagedTahesab" :key="c.code"
class="cs-mobile-row" 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 <a-checkbox
:checked="selectedTahesab.has(c.code)" :checked="selectedTahesab.has(c.code)"
:disabled="tahesabDone.has(c.code)" :disabled="!rowHasPhone(c) || tahesabDone.has(c.code)"
class="cs-mobile-row__check" class="cs-mobile-row__check"
@change="toggleTahesab(c.code)" @change="toggleTahesab(c.code)"
/> />
<div class="cs-mobile-row__info"> <div class="cs-mobile-row__info">
<div class="cs-mobile-row__name">{{ c.name }}</div> <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> </div>
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span> <span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
<a-button v-else type="primary" ghost size="small" <span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
@click="importOne(c)"> <a-button v-else type="primary" ghost size="small" @click="importOne(c)">
وارد کردن وارد کردن
</a-button> </a-button>
</div> </div>
@@ -274,25 +302,30 @@
<table class="cs-table"> <table class="cs-table">
<thead><tr> <thead><tr>
<th><a-checkbox :checked="allPageSelected" :indeterminate="someSelected" @change="toggleAll" /></th> <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> </tr></thead>
<tbody> <tbody>
<tr v-for="(c, idx) in pagedTahesab" :key="c.code" <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> <td>
<a-checkbox <a-checkbox
:checked="selectedTahesab.has(c.code)" :checked="selectedTahesab.has(c.code)"
:disabled="tahesabDone.has(c.code)" :disabled="!rowHasPhone(c) || tahesabDone.has(c.code)"
@change="toggleTahesab(c.code)" @change="toggleTahesab(c.code)"
/> />
</td> </td>
<td class="cs-table__num">{{ (tahesabPage - 1) * 20 + idx + 1 }}</td> <td class="cs-table__num">{{ (tahesabPage - 1) * 20 + idx + 1 }}</td>
<td class="cs-table__name">{{ c.name }}</td> <td class="cs-table__name">{{ c.name }}</td>
<td class="cs-table__mobile">{{ c.tel || '—' }}</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__mobile">{{ c.code }}</td>
<td class="cs-table__date">{{ c.group || '—' }}</td> <td class="cs-table__date">{{ c.group || '—' }}</td>
<td class="cs-table__action"> <td class="cs-table__action">
<span v-if="tahesabDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span> <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)"> <a-button v-else type="primary" ghost size="small" @click="importOne(c)">
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template> <template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
وارد کردن وارد کردن
@@ -322,8 +355,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch, createVNode } from 'vue'
import { message } from 'ant-design-vue/es' 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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { import {
@@ -331,6 +365,8 @@ import {
syncPanelToTahesab, syncPanelToTahesab,
importFromTahesab, importFromTahesab,
registerCustomerInTahesab, registerCustomerInTahesab,
linkTahesabMatchingPhones,
fetchTahesabMandeByCodes,
type CustomerSyncStatus, type CustomerSyncStatus,
type PanelOnlyCustomer, type PanelOnlyCustomer,
type TahesabOnlyCustomer, type TahesabOnlyCustomer,
@@ -338,7 +374,8 @@ import {
} from '@/api/tahesab' } from '@/api/tahesab'
import { useCustomerImportStore } from '@/store/customerImportStore' import { useCustomerImportStore } from '@/store/customerImportStore'
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue' 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() const importStore = useCustomerImportStore()
@@ -369,6 +406,40 @@ const tahesabDone = reactive(new Set<number>())
const selectedTahesab = reactive(new Set<number>()) const selectedTahesab = reactive(new Set<number>())
const tahesabSyncResult = ref<SyncPanelResult | null>(null) const tahesabSyncResult = ref<SyncPanelResult | null>(null)
const showTahesabErrors = ref(false) 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 ─── // ─── computed ───
const registeredPercent = computed(() => { const registeredPercent = computed(() => {
@@ -407,12 +478,12 @@ const pagedTahesab = computed(() => {
return filteredTahesabList.value.slice(s, s + 20) return filteredTahesabList.value.slice(s, s + 20)
}) })
const allPageSelected = computed(() => const allPageSelected = computed(() => {
pagedTahesab.value.length > 0 && const importable = pagedTahesab.value.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
pagedTahesab.value.every(c => selectedTahesab.has(c.code) || tahesabDone.has(c.code)) return importable.length > 0 && importable.every(c => selectedTahesab.has(c.code))
) })
const someSelected = computed(() => 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) ─── // ─── load panel only (سریع — بدون Tahesab API) ───
@@ -453,11 +524,19 @@ async function loadTahesab() {
if (status.value) { if (status.value) {
status.value.tahesab_total = res.tahesab_total ?? 0 status.value.tahesab_total = res.tahesab_total ?? 0
status.value.tahesab_only_count = res.tahesab_only_count ?? 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 { } else {
status.value = res status.value = res
} }
tahesabOnlyList.value = res.tahesab_only_list?.data ?? [] tahesabOnlyList.value = res.tahesab_only_list?.data ?? []
tahesabFetched.value = true tahesabFetched.value = true
await enrichPageMande()
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
finally { loadingTahesab.value = false } finally { loadingTahesab.value = false }
} }
@@ -528,31 +607,70 @@ async function syncAllPanel() {
// ─── تب ۲: عملیات ─── // ─── تب ۲: عملیات ───
function toggleTahesab(code: number) { 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) if (selectedTahesab.has(code)) selectedTahesab.delete(code)
else selectedTahesab.add(code) else selectedTahesab.add(code)
} }
function toggleAll() { function toggleAll() {
const importable = pagedTahesab.value.filter(c => rowHasPhone(c) && !tahesabDone.has(c.code))
if (allPageSelected.value) { if (allPageSelected.value) {
pagedTahesab.value.forEach(c => selectedTahesab.delete(c.code)) importable.forEach(c => selectedTahesab.delete(c.code))
} else { } 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[]) { 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 syncingTahesab.value = true
tahesabSyncResult.value = null tahesabSyncResult.value = null
showTahesabErrors.value = false showTahesabErrors.value = false
try { try {
const res = await importFromTahesab(customers) const res = await importFromTahesab(ready)
tahesabSyncResult.value = res tahesabSyncResult.value = res
if (res.success) { if (res.success) {
message.success(`${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) { if (status.value?.tahesab_only_count != null) {
status.value.tahesab_only_count = Math.max(0, status.value.tahesab_only_count - res.success) 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} مشتری وارد نشد`) if (res.failed) message.warning(`${res.failed} مشتری وارد نشد`)
await loadPanel(panelPagination.value.page) await loadPanel(panelPagination.value.page)
@@ -561,20 +679,73 @@ async function doImport(customers: TahesabOnlyCustomer[]) {
finally { syncingTahesab.value = false } finally { syncingTahesab.value = false }
} }
async function importOne(customer: TahesabOnlyCustomer) { await doImport([customer]) } async function importOne(customer: TahesabOnlyCustomer) {
async function importSelected() { await doImport(filteredTahesabList.value.filter(c => selectedTahesab.has(c.code))) } 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() { async function importAll() {
const pending = filteredTahesabList.value.filter(c => !tahesabDone.has(c.code)) const pending = importableList.value
if (!pending.length) return if (!pending.length) return
if (importStore.active) { if (importStore.active) {
importStore.expand() importStore.expand()
return 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, () => { watch(() => importStore.doneCodes.length, () => {
importStore.doneCodes.forEach(code => tahesabDone.add(code)) importStore.doneCodes.forEach(code => tahesabDone.add(code))
@@ -602,104 +773,108 @@ onMounted(() => {
.cs-wrap { .cs-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 10px;
padding: 8px 4px 24px; padding: 4px 0 16px;
} }
/* ─── بنر ─── */ /* ─── آمار فشرده ─── */
.cs-banner { .cs-banner {
border-radius: 14px; border-radius: 10px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 55%, #0ea5e9 100%); border: 1px solid var(--admin-border);
padding: 14px 16px; background: var(--admin-bg-surface);
color: #fff; padding: 10px 12px;
box-shadow: 0 4px 20px rgba(79, 70, 229, 0.35);
display: flex; display: flex;
flex-direction: column; 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; gap: 8px;
} }
/* ردیف تفاوت */ .cs-banner__top {
.cs-stats-diff { 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; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 6px;
} }
.cs-sep-arrows { @media (min-width: 640px) {
display: flex; .cs-stats {
flex-direction: column; grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center; }
color: rgba(255,255,255,0.4);
flex-shrink: 0;
line-height: 1;
} }
.cs-stat { .cs-stat {
border-radius: 10px; border-radius: 8px;
padding: 8px 10px; border: 1px solid var(--admin-border);
background: var(--admin-bg-muted);
padding: 6px 8px;
text-align: center; text-align: center;
flex: 1; min-width: 0;
&--neutral { background: rgba(255,255,255,0.12); } &--amber { border-color: rgba(245, 158, 11, 0.35); }
&--tahesab { background: rgba(14,165,233,0.25); } &--sky { border-color: rgba(14, 165, 233, 0.35); }
&--amber { background: rgba(251,191,36,0.2); }
&--sky { background: rgba(14,165,233,0.2); }
&__num { &__num {
font-size: 20px; font-size: 15px;
font-weight: 800; font-weight: 700;
line-height: 1; line-height: 1.2;
min-height: 26px; color: var(--admin-text);
min-height: 18px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@media (min-width: 640px) { font-size: 24px; }
} }
&__label { &__label {
display: block;
margin-top: 2px;
font-size: 10px; font-size: 10px;
color: rgba(255,255,255,0.65); color: var(--admin-text-secondary);
margin-top: 3px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
&__hint { font-size: 10px; color: rgba(255,255,255,0.4); font-weight: 400; }
}
/* progress */ &__hint {
.cs-progress { font-size: 11px;
&__labels { color: var(--admin-text-secondary);
display: flex; font-weight: 400;
justify-content: space-between;
font-size: 10px;
color: rgba(255,255,255,0.6);
margin-bottom: 5px;
} }
} }
.cs-track { .cs-track {
height: 7px; height: 4px;
border-radius: 99px; border-radius: 99px;
background: rgba(255,255,255,0.2); background: var(--admin-border);
overflow: hidden; overflow: hidden;
&__fill { &__fill {
height: 100%; height: 100%;
border-radius: 99px; border-radius: 99px;
background: #34d399; background: var(--admin-primary, #d4af37);
transition: width 0.7s ease; transition: width 0.5s ease;
} }
} }
@@ -707,7 +882,7 @@ onMounted(() => {
.cs-tabs { .cs-tabs {
display: flex; display: flex;
gap: 0; gap: 0;
border-bottom: 2px solid var(--admin-border); border-bottom: 1px solid var(--admin-border);
overflow-x: auto; overflow-x: auto;
&::-webkit-scrollbar { display: none; } &::-webkit-scrollbar { display: none; }
@@ -717,26 +892,24 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding: 8px 12px; padding: 7px 12px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--admin-text-secondary); color: var(--admin-text-secondary);
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
margin-bottom: -2px; margin-bottom: -1px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: color 0.15s, border-color 0.15s;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
@media (min-width: 640px) { padding: 8px 16px; font-size: 13px; }
&:hover { color: var(--admin-text); } &:hover { color: var(--admin-text); }
&--active { &--active {
color: #7c3aed; color: var(--admin-primary, #d4af37);
border-bottom-color: #7c3aed; border-bottom-color: var(--admin-primary, #d4af37);
} }
&__badge { &__badge {
@@ -786,11 +959,41 @@ onMounted(() => {
&__head-actions { &__head-actions {
display: flex; display: flex;
gap: 6px; flex-direction: column;
align-items: center; align-items: stretch;
flex-wrap: wrap; 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; } &--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 ─── */ /* ─── نتیجه sync ─── */
.cs-result { .cs-result {
@@ -847,6 +1059,7 @@ onMounted(() => {
transition: opacity 0.2s; transition: opacity 0.2s;
&--done { opacity: 0.45; } &--done { opacity: 0.45; }
&--no-phone { opacity: 0.55; filter: grayscale(0.35); }
&__check { flex-shrink: 0; } &__check { flex-shrink: 0; }
&__info { flex: 1; min-width: 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); } 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; } 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:hover { background: var(--admin-bg-hover); }
tbody tr.cs-table__row--done { opacity: 0.45; } 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); } td { padding: 10px 14px; color: var(--admin-text); }
&__num { color: var(--admin-text-secondary); font-size: 12px; width: 40px; } &__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); } &__mobile { font-family: monospace; font-size: 12px; color: var(--admin-text-muted); }
&__date { font-size: 12px; color: var(--admin-text-secondary); } &__date { font-size: 12px; color: var(--admin-text-secondary); }
&__action { text-align: center; white-space: nowrap; } &__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 { .cs-done-badge {
@@ -1,39 +1,36 @@
<template> <template>
<div class="tw-bulk"> <div class="tw-bulk">
<div class="tw-bulk__head"> <div class="tw-bulk__row">
<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__copy min-w-0 flex-1"> <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 class="tw-bulk__hint">
موجودی کیف همهٔ مشتریان دارای کد ته‌حساب را با ماندهٔ ته‌حساب یکی می‌کند. همگام تکی: اول سندها و اختلاف را می‌بینید، بعد تأیید می‌کنید. دو مانده جمع نمی‌شوند.
اگر مشتری در اپ مانده داشته باشد و با ته‌حساب فرق کند، در جزئیات نشان داده می‌شود تا جداگانه تصمیم بگیرید. همگام همه فقط کیف خالی را می‌نشاند؛ تداخل برای تصمیم دستی می‌ماند.
</p> </p>
</div> </div>
</div> <div class="tw-bulk__actions">
<a-button
<div class="tw-bulk__actions"> type="primary"
<a-button size="small"
type="primary" :loading="running && !overwriteMode"
block :disabled="running"
class="tw-bulk__btn" @click="confirmAndStart(false)"
:loading="running && !overwriteMode" >
:disabled="running" همگام‌سازی
@click="confirmAndStart(false)" </a-button>
> <a-button
همگام‌سازی مانده (بدون بازنویسی تداخل‌ها) size="small"
</a-button> danger
<a-button :loading="running && overwriteMode"
block :disabled="running"
danger @click="confirmAndStart(true)"
class="tw-bulk__btn" >
:loading="running && overwriteMode" اعمال اجباری
:disabled="running" </a-button>
@click="confirmAndStart(true)" </div>
>
اعمال اجباری مانده ته‌حساب برای همه
</a-button>
</div> </div>
<div v-if="running || resultVisible" class="tw-bulk__progress"> <div v-if="running || resultVisible" class="tw-bulk__progress">
@@ -52,8 +49,8 @@
<template v-else-if="resultVisible"> <template v-else-if="resultVisible">
<div class="tw-bulk__progress-row"> <div class="tw-bulk__progress-row">
<span class="tw-bulk__ok">✓ {{ success }} موفق</span> <span class="tw-bulk__ok">✓ {{ success }} موفق</span>
<span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر موجودی</span> <span v-if="changed" class="tw-bulk__changed">{{ changed }} تغییر</span>
<span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} نیازمند تصمیم</span> <span v-if="needsDecision" class="tw-bulk__decision">{{ needsDecision }} تصمیم</span>
<span v-if="failed" class="tw-bulk__fail">✗ {{ failed }} ناموفق</span> <span v-if="failed" class="tw-bulk__fail">✗ {{ failed }} ناموفق</span>
<a-button <a-button
v-if="errors.length || decisions.length" v-if="errors.length || decisions.length"
@@ -66,12 +63,25 @@
<a-button type="link" size="small" @click="dismissResult">بستن</a-button> <a-button type="link" size="small" @click="dismissResult">بستن</a-button>
</div> </div>
<div v-if="showDetails && decisions.length" class="tw-bulk__decisions"> <div v-if="showDetails && decisions.length" class="tw-bulk__decisions">
<div class="tw-bulk__decisions-title">تداخل موجودی — از صفحه مشتری تصمیم بگیرید:</div> <div class="tw-bulk__decisions-title">تداخل مانده — کدام را نگه داریم؟ (جمع نمی‌شود)</div>
<div v-for="d in decisions" :key="d.id"> <div v-for="d in decisions" :key="d.id" class="tw-bulk__decision-row">
<b>{{ d.name }}</b> <div>
<template v-if="d.mobile"> ({{ d.mobile }})</template> <b>{{ d.name }}</b>
— اپ: {{ formatGold(d.app_gold) }} طلا / {{ formatCash(d.app_cash) }} ریال <template v-if="d.mobile"> ({{ d.mobile }})</template>
· ته‌حساب: {{ formatGold(d.tahesab_gold) }} طلا / {{ formatCash(d.tahesab_cash) }} ریال — اپ: {{ 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> </div>
<div v-if="showDetails && errors.length" class="tw-bulk__errors"> <div v-if="showDetails && errors.length" class="tw-bulk__errors">
@@ -83,18 +93,95 @@
</div> </div>
</template> </template>
</div> </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> </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> </template>
<script setup lang="ts"> <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 { Modal, message } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue' import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { import {
getTahesabWalletLinkedList,
previewCustomerWalletFromTahesab,
syncAllWalletsFromTahesabChunk, syncAllWalletsFromTahesabChunk,
syncCustomerWalletFromTahesab,
type TahesabBulkWalletSyncChunk, type TahesabBulkWalletSyncChunk,
type TahesabWalletLinkedRow,
type TahesabWalletSyncResult,
} from '@/api/tahesab' } 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 CHUNK_LIMIT = 20
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
@@ -112,26 +199,168 @@ const needsDecision = ref(0)
const failed = ref(0) const failed = ref(0)
const errors = ref<TahesabBulkWalletSyncChunk['errors']>([]) const errors = ref<TahesabBulkWalletSyncChunk['errors']>([])
const decisions = ref<TahesabBulkWalletSyncChunk['decisions']>([]) 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(() => const percent = computed(() =>
total.value > 0 ? Math.min(100, Math.round((processed.value / total.value) * 100)) : 0, 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 '—' if (v == null || Number.isNaN(v)) return '—'
return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 }) return Number(v).toLocaleString('fa-IR', { maximumFractionDigits: 3 })
} }
function formatCash(v?: number) { function formatCash(v?: number | null) {
if (v == null || Number.isNaN(v)) return '—' if (v == null || Number.isNaN(v)) return '—'
return Math.round(Number(v)).toLocaleString('fa-IR') 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() { function dismissResult() {
resultVisible.value = false resultVisible.value = false
showDetails.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) { function confirmAndStart(overwrite: boolean) {
if (running.value) return if (running.value) return
Modal.confirm({ Modal.confirm({
@@ -140,8 +369,8 @@ function confirmAndStart(overwrite: boolean) {
: 'همگام‌سازی ماندهٔ همهٔ مشتریان؟', : 'همگام‌سازی ماندهٔ همهٔ مشتریان؟',
icon: createVNode(ExclamationCircleOutlined), icon: createVNode(ExclamationCircleOutlined),
content: overwrite content: overwrite
? 'موجودی کیف همهٔ مشتریان دارای کد ته‌حساب با ماندهٔ ته‌حساب جایگزین می‌شود — حتی اگر در اپ مانده داشته باشند.' ? 'موجودی کیف همهٔ مشتریان دارای کد ته‌حساب با ماندهٔ ته‌حساب جایگزین می‌شود. دو مانده جمع نمی‌شوند. این کار برگشت‌پذیر نیست مگر با سند جدید.'
: 'برای مشتریانی که کیف خالی دارند مانده ته‌حساب می‌نشیند. اگر اپ مانده داشته باشد و فرق کند، در لیست «نیازمند تصمیم» می‌آید.', : 'برای مشتریانی که کیف خالی دارند مانده ته‌حساب می‌نشیند. اگر اپ مانده داشته باشد و فرق کند، جمع نمی‌شود و در لیست «نیازمند تصمیم» می‌آید تا سندها را ببینید و تأیید کنید.',
okText: overwrite ? 'بله، برای همه اعمال کن' : 'شروع همگام‌سازی', okText: overwrite ? 'بله، برای همه اعمال کن' : 'شروع همگام‌سازی',
okType: overwrite ? 'danger' : 'primary', okType: overwrite ? 'danger' : 'primary',
cancelText: 'انصراف', cancelText: 'انصراف',
@@ -213,45 +442,55 @@ async function start(overwrite: boolean) {
} finally { } finally {
running.value = false running.value = false
overwriteMode.value = false overwriteMode.value = false
void loadList()
} }
} }
</script> </script>
<style scoped> <style scoped>
.tw-bulk { .tw-bulk {
@apply rounded-xl border border-base p-3; @apply flex min-h-0 flex-col gap-2.5 rounded-lg border border-base p-3;
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc)); 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 { @media (min-width: 640px) {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-50; .tw-bulk__row {
@apply flex-row items-center gap-3;
}
} }
.tw-bulk__title { .tw-bulk__title {
@apply m-0 text-sm font-700; @apply m-0 text-sm font-600;
color: var(--admin-text, inherit); color: var(--admin-text, inherit);
} }
.tw-bulk__title-row {
@apply flex items-center gap-1.5;
}
.tw-bulk__hint { .tw-bulk__hint {
@apply m-0 mt-0.5 text-xs leading-5; @apply m-0 mt-0.5 text-xs leading-5;
color: var(--admin-text-secondary, #6b7280); color: var(--admin-text-secondary, #6b7280);
} }
.tw-bulk__actions { .tw-bulk__actions {
@apply flex flex-col gap-2; @apply flex flex-wrap items-center gap-1.5;
} }
.tw-bulk__btn { @media (min-width: 640px) {
@apply font-600; .tw-bulk__actions {
@apply shrink-0;
}
} }
.tw-bulk__progress { .tw-bulk__progress {
@apply mt-3 rounded-lg border border-base p-2.5 text-xs; @apply mt-2 rounded-md border border-base p-2 text-xs;
background: var(--admin-bg-surface, #fff); background: var(--admin-bg-muted, transparent);
color: var(--admin-text, inherit); color: var(--admin-text, inherit);
} }
@@ -260,54 +499,100 @@ async function start(overwrite: boolean) {
} }
.tw-bulk__track { .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); background: var(--admin-border, #e5e7eb);
} }
.tw-bulk__track-fill { .tw-bulk__track-fill {
@apply h-full rounded-full transition-all duration-300; @apply h-full rounded-full transition-all duration-300;
background: var(--admin-primary, #10913A); background: var(--admin-primary, #d4af37);
} }
.tw-bulk__ok { .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); background: rgba(52, 211, 153, 0.15);
color: #059669; color: #059669;
} }
.tw-bulk__changed { .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); background: rgba(14, 165, 233, 0.12);
color: #0284c7; color: #0284c7;
} }
.tw-bulk__decision { .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); background: rgba(245, 158, 11, 0.15);
color: #d97706; color: #d97706;
} }
.tw-bulk__fail { .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); background: rgba(239, 68, 68, 0.12);
color: #dc2626; color: #dc2626;
} }
.tw-bulk__decisions { .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); background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25); border: 1px solid rgba(245, 158, 11, 0.25);
color: #b45309; color: #b45309;
} }
.tw-bulk__decisions-title { .tw-bulk__decision-row {
@apply mb-1 font-700; @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 { .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); background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.2);
color: #dc2626; 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> </style>
@@ -5,7 +5,10 @@
<!-- ─── بنر آمار ─── --> <!-- ─── بنر آمار ─── -->
<div class="cs-banner"> <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"> <div class="cs-stats-main">
@@ -16,17 +19,21 @@
</div> </div>
<div class="cs-stat__label">مشتریان پنل</div> <div class="cs-stat__label">مشتریان پنل</div>
</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 cs-stat--zarin cs-stat--clickable" role="button" tabindex="0" @click="onTabChange('zarin')" @keydown.enter="onTabChange('zarin')">
<div class="cs-stat__num"> <div class="cs-stat__num">
<template v-if="status?.zarin_total != null">{{ status.zarin_total }}</template> <template v-if="status?.zarin_total != null">{{ status.zarin_total }}</template>
<a-spin v-else-if="loadingZarin" size="small" /> <a-spin v-else-if="loadingZarin" size="small" />
<span v-else class="cs-stat__hint">کلیک کنید</span> <span v-else class="cs-stat__hint">کلیک کنید</span>
</div> </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>
</div> </div>
@@ -42,11 +49,11 @@
</div> </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 cs-stat--sky cs-stat--clickable" role="button" tabindex="0" @click="onTabChange('zarin')" @keydown.enter="onTabChange('zarin')">
<div class="cs-stat__num"> <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" /> <a-spin v-else-if="loadingZarin" size="small" />
<span v-else class="cs-stat__hint">کلیک کنید</span> <span v-else class="cs-stat__hint">کلیک کنید</span>
</div> </div>
<div class="cs-stat__label">زرین — بدون پنل</div> <div class="cs-stat__label">قابل وارد کردن</div>
</div> </div>
</div> </div>
@@ -67,8 +74,7 @@
:class="{ 'cs-tab--active': activeTab === 'panel' }" :class="{ 'cs-tab--active': activeTab === 'panel' }"
@click="onTabChange('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> <span v-if="status?.panel_only" class="cs-tab__badge cs-tab__badge--amber">{{ status.panel_only }}</span>
</button> </button>
<button <button
@@ -76,9 +82,8 @@
:class="{ 'cs-tab--active': activeTab === 'zarin' }" :class="{ 'cs-tab--active': activeTab === 'zarin' }"
@click="onTabChange('zarin')" @click="onTabChange('zarin')"
> >
<Icon icon="vuesax-linear:arrow-right" :size="14" /> از زرین به پنل
زرین ← پنل <span v-if="status?.zarin_importable_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.zarin_importable_count }}</span>
<span v-if="status?.zarin_only_count" class="cs-tab__badge cs-tab__badge--sky">{{ status.zarin_only_count }}</span>
<span v-else-if="!zarinFetched && !loadingZarin" class="cs-tab__hint">بارگذاری</span> <span v-else-if="!zarinFetched && !loadingZarin" class="cs-tab__hint">بارگذاری</span>
</button> </button>
</div> </div>
@@ -190,6 +195,28 @@
<!-- ─── تب ۲: زرین → پنل ─── --> <!-- ─── تب ۲: زرین → پنل ─── -->
<div v-show="activeTab === 'zarin'" class="cs-card"> <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">
<div class="cs-card__head-right"> <div class="cs-card__head-right">
<span class="cs-dot cs-dot--sky" /> <span class="cs-dot cs-dot--sky" />
@@ -204,19 +231,19 @@
type="primary" type="primary"
size="small" size="small"
:loading="syncingZarin" :loading="syncingZarin"
:disabled="!status?.zarin_only_count || selectedZarin.size === 0" :disabled="importableSelectedCount === 0"
@click="importSelected" @click="importSelected"
> >
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template> <template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
وارد کردن انتخاب‌شده‌ها ({{ selectedZarin.size }}) وارد کردن انتخاب‌شده ({{ importableSelectedCount }})
</a-button> </a-button>
<a-button <a-button
size="small" size="small"
:loading="importStore.active" :loading="importStore.active"
:disabled="!zarinOnlyList.length" :disabled="!importableCount"
@click="importAll" @click="importAll"
> >
{{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${status?.zarin_only_count ?? 0})` }} {{ importStore.active ? 'در حال وارد کردن…' : `وارد کردن همه (${importableCount})` }}
</a-button> </a-button>
</div> </div>
</div> </div>
@@ -264,21 +291,24 @@
<div <div
v-for="c in pagedZarin" :key="c.code" v-for="c in pagedZarin" :key="c.code"
class="cs-mobile-row" 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 <a-checkbox
:checked="selectedZarin.has(c.code)" :checked="selectedZarin.has(c.code)"
:disabled="zarinDone.has(c.code)" :disabled="!rowHasPhone(c) || zarinDone.has(c.code)"
class="cs-mobile-row__check" class="cs-mobile-row__check"
@change="toggleZarin(c.code)" @change="toggleZarin(c.code)"
/> />
<div class="cs-mobile-row__info"> <div class="cs-mobile-row__info">
<div class="cs-mobile-row__name">{{ c.name }}</div> <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> </div>
<span v-if="zarinDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span> <span v-if="zarinDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span>
<a-button v-else type="primary" ghost size="small" <span v-else-if="!rowHasPhone(c)" class="cs-skip-badge">شماره لازم است</span>
@click="importOne(c)"> <a-button v-else type="primary" ghost size="small" @click="importOne(c)">
وارد کردن وارد کردن
</a-button> </a-button>
</div> </div>
@@ -287,25 +317,30 @@
<table class="cs-table"> <table class="cs-table">
<thead><tr> <thead><tr>
<th><a-checkbox :checked="allPageSelected" :indeterminate="someSelected" @change="toggleAll" /></th> <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> </tr></thead>
<tbody> <tbody>
<tr v-for="(c, idx) in pagedZarin" :key="c.code" <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> <td>
<a-checkbox <a-checkbox
:checked="selectedZarin.has(c.code)" :checked="selectedZarin.has(c.code)"
:disabled="zarinDone.has(c.code)" :disabled="!rowHasPhone(c) || zarinDone.has(c.code)"
@change="toggleZarin(c.code)" @change="toggleZarin(c.code)"
/> />
</td> </td>
<td class="cs-table__num">{{ (zarinPage - 1) * 20 + idx + 1 }}</td> <td class="cs-table__num">{{ (zarinPage - 1) * 20 + idx + 1 }}</td>
<td class="cs-table__name">{{ c.name }}</td> <td class="cs-table__name">{{ c.name }}</td>
<td class="cs-table__mobile">{{ c.tel || '—' }}</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__mobile">{{ c.code }}</td>
<td class="cs-table__date">{{ c.group || '—' }}</td> <td class="cs-table__date">{{ c.group || '—' }}</td>
<td class="cs-table__action"> <td class="cs-table__action">
<span v-if="zarinDone.has(c.code)" class="cs-done-badge">✓ وارد شد</span> <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)"> <a-button v-else type="primary" ghost size="small" @click="importOne(c)">
<template #icon><Icon icon="vuesax-linear:import" :size="13" /></template> <template #icon><Icon icon="vuesax-linear:import" :size="13" /></template>
وارد کردن وارد کردن
@@ -336,8 +371,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch, createVNode } from 'vue'
import { message } from 'ant-design-vue/es' 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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { import {
@@ -345,6 +381,7 @@ import {
syncPanelToZarin, syncPanelToZarin,
importFromZarin, importFromZarin,
registerCustomerInZarin, registerCustomerInZarin,
linkZarinMatchingPhones,
type CustomerSyncStatus, type CustomerSyncStatus,
type PanelOnlyCustomer, type PanelOnlyCustomer,
type ZarinOnlyCustomer, type ZarinOnlyCustomer,
@@ -352,6 +389,8 @@ import {
} from '@/api/zarin' } from '@/api/zarin'
import { useCustomerImportStore } from '@/store/customerImportStore' import { useCustomerImportStore } from '@/store/customerImportStore'
import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue' import ColleagueAccountingAccounts from '@/pages/menuPages/colleagueOrders/components/ColleagueAccountingAccounts.vue'
import { accountingZarinCustomerSyncIntro } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
const importStore = useCustomerImportStore() const importStore = useCustomerImportStore()
@@ -382,6 +421,40 @@ const zarinDone = reactive(new Set<number>())
const selectedZarin = reactive(new Set<number>()) const selectedZarin = reactive(new Set<number>())
const zarinSyncResult = ref<SyncPanelResult | null>(null) const zarinSyncResult = ref<SyncPanelResult | null>(null)
const showZarinErrors = ref(false) 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 ─── // ─── computed ───
const registeredPercent = computed(() => { const registeredPercent = computed(() => {
@@ -396,12 +469,12 @@ const pagedZarin = computed(() => {
return zarinOnlyList.value.slice(s, s + 20) return zarinOnlyList.value.slice(s, s + 20)
}) })
const allPageSelected = computed(() => const allPageSelected = computed(() => {
pagedZarin.value.length > 0 && const importable = pagedZarin.value.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
pagedZarin.value.every(c => selectedZarin.has(c.code) || zarinDone.has(c.code)) return importable.length > 0 && importable.every(c => selectedZarin.has(c.code))
) })
const someSelected = computed(() => 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 زرین) ─── // ─── load panel only (سریع — بدون API زرین) ───
@@ -443,6 +516,13 @@ async function loadZarin(force = false) {
if (status.value) { if (status.value) {
status.value.zarin_total = res.zarin_total ?? 0 status.value.zarin_total = res.zarin_total ?? 0
status.value.zarin_only_count = res.zarin_only_count ?? 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.zarin_group_filter = res.zarin_group_filter ?? null
status.value.include_zarin = res.include_zarin ?? false status.value.include_zarin = res.include_zarin ?? false
status.value.zarin_fetch_error = null status.value.zarin_fetch_error = null
@@ -542,31 +622,70 @@ async function syncAllPanel() {
// ─── تب ۲: عملیات ─── // ─── تب ۲: عملیات ───
function toggleZarin(code: number) { 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) if (selectedZarin.has(code)) selectedZarin.delete(code)
else selectedZarin.add(code) else selectedZarin.add(code)
} }
function toggleAll() { function toggleAll() {
const importable = pagedZarin.value.filter(c => rowHasPhone(c) && !zarinDone.has(c.code))
if (allPageSelected.value) { if (allPageSelected.value) {
pagedZarin.value.forEach(c => selectedZarin.delete(c.code)) importable.forEach(c => selectedZarin.delete(c.code))
} else { } 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[]) { 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 syncingZarin.value = true
zarinSyncResult.value = null zarinSyncResult.value = null
showZarinErrors.value = false showZarinErrors.value = false
try { try {
const res = await importFromZarin(customers) const res = await importFromZarin(ready)
zarinSyncResult.value = res zarinSyncResult.value = res
if (res.success) { if (res.success) {
message.success(`${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) { if (status.value?.zarin_only_count != null) {
status.value.zarin_only_count = Math.max(0, status.value.zarin_only_count - res.success) 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} مشتری وارد نشد`) if (res.failed) message.warning(`${res.failed} مشتری وارد نشد`)
await loadPanel(panelPagination.value.page) await loadPanel(panelPagination.value.page)
@@ -575,18 +694,54 @@ async function doImport(customers: ZarinOnlyCustomer[]) {
finally { syncingZarin.value = false } finally { syncingZarin.value = false }
} }
async function importOne(customer: ZarinOnlyCustomer) { await doImport([customer]) } async function importOne(customer: ZarinOnlyCustomer) {
async function importSelected() { await doImport(zarinOnlyList.value.filter(c => selectedZarin.has(c.code))) } 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() { async function importAll() {
const pending = zarinOnlyList.value.filter(c => !zarinDone.has(c.code)) const pending = importableList.value
if (!pending.length) return if (!pending.length) return
if (importStore.active) { if (importStore.active) {
importStore.expand() importStore.expand()
return 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; } @media (min-width: 640px) { padding: 20px 24px; gap: 16px; }
&__title { font-size: 14px; font-weight: 700; } &__title { font-size: 14px; font-weight: 700; }
&__title-row { display: inline-flex; align-items: center; gap: 6px; }
} }
/* ردیف اصلی: پنل ↔ زرین */ /* ردیف اصلی: پنل ↔ زرین */
.cs-stats-main { .cs-stats-main {
display: flex; display: grid;
align-items: center; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
@@ -902,6 +1058,7 @@ onMounted(() => {
transition: opacity 0.2s; transition: opacity 0.2s;
&--done { opacity: 0.45; } &--done { opacity: 0.45; }
&--no-phone { opacity: 0.55; filter: grayscale(0.35); }
&__check { flex-shrink: 0; } &__check { flex-shrink: 0; }
&__info { flex: 1; min-width: 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); } 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; } 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:hover { background: var(--admin-bg-hover); }
tbody tr.cs-table__row--done { opacity: 0.45; } 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); } td { padding: 10px 14px; color: var(--admin-text); }
&__num { color: var(--admin-text-secondary); font-size: 12px; width: 40px; } &__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); } &__mobile { font-family: monospace; font-size: 12px; color: var(--admin-text-muted); }
&__date { font-size: 12px; color: var(--admin-text-secondary); } &__date { font-size: 12px; color: var(--admin-text-secondary); }
&__action { text-align: center; white-space: nowrap; } &__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 { .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> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <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"> <div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }"> <template #action="{ record }">
@@ -137,6 +137,7 @@ import {
pargarTypeLabel pargarTypeLabel
} from '@/pages/menuPages/accounting/utils/pargarDisplay' } from '@/pages/menuPages/accounting/utils/pargarDisplay'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { accountingPargarDocsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,6 @@
<template> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <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"> <div v-if="!isMobile" class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" /> <BaseTable v-bind="tblProps" @register="submit" />
</div> </div>
@@ -18,6 +18,7 @@ import AccountingMobileCardList from '@/pages/menuPages/accounting/components/Ac
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable' import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable' import type { TableInput } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { accountingReportsPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,9 @@
<template> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext :title="customerId ? 'اسناد ته‌حساب این مشتری' : 'اسناد ته‌حساب'"> <BasicShowContext
:title="customerId ? 'اسناد ته‌حساب این مشتری' : 'اسناد ته‌حساب'"
:help="accountingTahesabDocsIntro"
>
<template #extra> <template #extra>
<a-button <a-button
v-if="(pending?.total ?? 0) > 0" v-if="(pending?.total ?? 0) > 0"
@@ -213,6 +216,7 @@ import {
rialAmountLabel rialAmountLabel
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts' } from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { accountingTahesabDocsIntro } from '@/utils/adminHelpCopy'
const props = defineProps<{ const props = defineProps<{
customerId?: number customerId?: number
@@ -1,6 +1,9 @@
<template> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <div class="min-w-0 w-full max-w-full overflow-x-hidden">
<BasicShowContext :title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'"> <BasicShowContext
:title="customerId ? 'اسناد زرین این مشتری' : 'اسناد زرین'"
:help="accountingZarinDocsIntro"
>
<template #extra> <template #extra>
<a-button <a-button
v-if="(pending?.total ?? 0) > 0" v-if="(pending?.total ?? 0) > 0"
@@ -212,6 +215,7 @@ import {
rialAmountLabel rialAmountLabel
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts' } from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { accountingZarinDocsIntro } from '@/utils/adminHelpCopy'
const props = defineProps<{ const props = defineProps<{
customerId?: number customerId?: number
+12 -3
View File
@@ -3,25 +3,31 @@
<BasicMainCard> <BasicMainCard>
<div <div
class="flex h-full w-full min-w-0 max-w-full flex-col overflow-x-hidden" 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" /> <PargarStuckOutboundAlert v-if="pargarEnabled" class="mb-3 shrink-0" />
<ATabs v-model:activeKey="activeTab" class="mb-3 shrink-0"> <ATabs v-model:activeKey="activeTab" class="mb-3 shrink-0">
<ATabPane key="reports" tab="گزارشات" /> <ATabPane key="reports" tab="گزارشات" />
<ATabPane v-if="tahesabEnabled" key="tahesab" tab="اسناد ته‌حساب" /> <ATabPane v-if="tahesabEnabled" key="tahesab" tab="اسناد ته‌حساب" />
<ATabPane v-if="tahesabEnabled" key="customer-sync" 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" tab="اسناد زرین" />
<ATabPane v-if="zarinEnabled" key="zarin-customer-sync" 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" tab="اسناد پرگار" />
<ATabPane v-if="pargarEnabled" key="pargar-customer-sync" tab="همگام سازی مشتریان در پرگار" /> <ATabPane v-if="pargarEnabled" key="pargar-customer-sync" tab="همگام سازی مشتریان در پرگار" />
<ATabPane v-if="pargarEnabled" key="pargar-wallet-sync" tab="مانده کیف پرگار" />
</ATabs> </ATabs>
<tbl v-if="activeTab === 'reports'" ref="table" class="min-h-0 flex-1" /> <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" /> <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" /> <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" /> <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" /> <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" /> <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" /> <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> </div>
</BasicMainCard> </BasicMainCard>
<BasicModal <BasicModal
@@ -148,14 +154,17 @@
// import BasicInfoModal from '@/components/BasicInfoModal' // import BasicInfoModal from '@/components/BasicInfoModal'
import { inputLenRules, inputRules, inputRulesNumber } from '@/utils/inputRules' import { inputLenRules, inputRules, inputRulesNumber } from '@/utils/inputRules'
import { useStaticApiListsStore } from '@/store/staticApisStore' import { useStaticApiListsStore } from '@/store/staticApisStore'
import { ref } from 'vue' import { ref, computed } from 'vue'
import tbl from './components/table.vue' import tbl from './components/table.vue'
import tahesabTbl from './components/tahesabTable.vue' import tahesabTbl from './components/tahesabTable.vue'
import customerSync from './components/TahesabCustomerSync.vue' import customerSync from './components/TahesabCustomerSync.vue'
import tahesabWalletSync from './components/TahesabWalletBulkSync.vue'
import zarinTbl from './components/zarinTable.vue' import zarinTbl from './components/zarinTable.vue'
import zarinCustomerSync from './components/ZarinCustomerSync.vue' import zarinCustomerSync from './components/ZarinCustomerSync.vue'
import zarinWalletSync from './components/ZarinWalletBulkSync.vue'
import pargarTbl from './components/pargarTable.vue' import pargarTbl from './components/pargarTable.vue'
import pargarCustomerSync from './components/PargarCustomerSync.vue' import pargarCustomerSync from './components/PargarCustomerSync.vue'
import pargarWalletSync from './components/PargarWalletBulkSync.vue'
import PargarStuckOutboundAlert from './components/PargarStuckOutboundAlert.vue' import PargarStuckOutboundAlert from './components/PargarStuckOutboundAlert.vue'
import type { FormInstance } from 'ant-design-vue' import type { FormInstance } from 'ant-design-vue'
import BasicInfoModal from '@/components/BasicInfoModal/index.vue' import BasicInfoModal from '@/components/BasicInfoModal/index.vue'
@@ -179,7 +188,7 @@ import { initAccountingSettingsFromApi, tahesabEnabledSetting, zarinEnabledSetti
//loading //loading
const loading = ref<boolean>(false) const loading = ref<boolean>(false)
const submitError = ref('') 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 tahesabEnabled = tahesabEnabledSetting
const zarinEnabled = zarinEnabledSetting const zarinEnabled = zarinEnabledSetting
const pargarEnabled = pargarEnabledSetting const pargarEnabled = pargarEnabledSetting
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BasicShowContext title="اعلان‌های اپ"> <BasicShowContext title="اعلان‌های اپ" :help="appNotificationsPageIntro">
<template #extra> <template #extra>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<a-radio-group <a-radio-group
@@ -17,9 +17,6 @@
{{ tab.label }} {{ tab.label }}
</a-radio-button> </a-radio-button>
</a-radio-group> </a-radio-group>
<AdminFieldHelp
text="اعلان ادمین را دستی می‌فرستید؛ اعلان سیستمی خودکار برای خرید/فروش/واریز/برداشت ساخته می‌شود. از تب‌ها برای جدا کردن لیست استفاده کنید."
/>
<BaseButton type="primary" size="small" @click="emits('add')"> <BaseButton type="primary" size="small" @click="emits('add')">
<span>افزودن اعلان</span> <span>افزودن اعلان</span>
<BaseIcon icon="vuesax-linear:add" size="16" /> <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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/appNotificationsColumns' import { cols } from '../hooks/appNotificationsColumns'
import { getAppNotificationsTable } from '@/api/appNotifications' import { getAppNotificationsTable } from '@/api/appNotifications'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue' import { appNotificationsPageIntro } from '@/utils/adminHelpCopy'
import { import {
APP_NOTIFICATION_SOURCE_TABS, APP_NOTIFICATION_SOURCE_TABS,
type AppNotificationSourceFilter, type AppNotificationSourceFilter,
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="اتوماسیون"> <BasicShowContext title="اتوماسیون" :help="automationsPageIntro">
<template #extra> <template #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')"> <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 { getAutomationsTable } from '@/api/automations'
import type { Automation } from '@/model/automations' import type { Automation } from '@/model/automations'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList' import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import { automationsPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
+4 -4
View File
@@ -122,7 +122,7 @@
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
<SettingsProfileBanners v-if="showSettingsProfileBanners" /> <SettingsProfileBanners v-if="showThemeBanners" />
</div> </div>
</div> </div>
</template> </template>
@@ -133,7 +133,7 @@ import SettingsProfileBanners from './components/SettingsProfileBanners.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue' import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue' import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin' import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import { hasAppUiConfig } from '@/utils/config' import { isModularUiVariant } from '@/utils/config'
import { import {
bannersEventHelp, bannersEventHelp,
bannersImageSectionHelp, bannersImageSectionHelp,
@@ -144,12 +144,12 @@ import {
bannersShareHelp, bannersShareHelp,
bannersShowHelp, bannersShowHelp,
} from '@/utils/adminHelpCopy' } 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 { state, saving, save, load, saveSoon } = useAppBannersAdmin()
/** اگر اسلایدری هست، پنل را باز نگه دار تا خالی به‌نظر نرسد */ /** اگر اسلایدری هست، پنل را باز نگه دار تا خالی به‌نظر نرسد */
const sliderActiveKey = ref<(string | number)[]>(['slider']) const sliderActiveKey = ref<(string | number)[]>(['slider'])
const showSettingsProfileBanners = computed(() => hasAppUiConfig())
watch( watch(
() => state.slider.length, () => state.slider.length,
+2 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="capture-sites-page"> <div class="capture-sites-page">
<BasicShowContext title="لیست سایت‌های ری‌گیری"> <BasicShowContext title="لیست سایت‌های ری‌گیری" :help="captureSitesPageIntro">
<template #extra> <template #extra>
<div class="capture-sites-page__toolbar"> <div class="capture-sites-page__toolbar">
<label v-if="enabledRow" class="capture-sites-page__switch"> <label v-if="enabledRow" class="capture-sites-page__switch">
@@ -118,6 +118,7 @@ import {
serializeTrackingSystemsEnabled, serializeTrackingSystemsEnabled,
} from '@/utils/reygiriSites' } from '@/utils/reygiriSites'
import { setTrackingSystemsEnabledFromSettings } from '@/utils/config' import { setTrackingSystemsEnabledFromSettings } from '@/utils/config'
import { captureSitesPageIntro } from '@/utils/adminHelpCopy'
const sites = ref<CaptureSite[]>([]) const sites = ref<CaptureSite[]>([])
const loading = ref(false) const loading = ref(false)
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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"> <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"> <BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader> <template #extraHeader>
@@ -62,6 +62,7 @@ import { changeAccountSts, getAccountTable } from '@/api/card'
import type { Card } from '@/model/card' import type { Card } from '@/model/card'
import AccountMobileCardList from './AccountMobileCardList.vue' import AccountMobileCardList from './AccountMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { cardsPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,31 +1,35 @@
<template> <template>
<section v-if="visible" class="colleague-accounts"> <section
<header class="colleague-accounts__head"> v-if="visible"
<div> 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"
<h3 class="colleague-accounts__title">کد حسابداری معامله با همکار</h3> >
<p class="colleague-accounts__desc"> <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)">
</p> کد حسابداری معامله با همکار
</div> <AdminFieldHelp :text="colleagueAccountingCodeHelp" size="sm" />
<BaseButton type="primary" :loading="saving" @click="save"> </h3>
ذخیره کد <p class="m-0 mt-0.5 text-xs leading-5" style="color: var(--admin-text-secondary)">
</BaseButton> سند خرید/فروش پوشش‌شده روی این کد در حسابداری فعال ثبت می‌شود.
</header> </p>
</div>
<label <label v-if="activeField" class="m-0 flex w-full min-w-0 flex-col gap-1">
v-if="activeField" <span class="inline-flex items-center gap-1.5 text-xs font-600" style="color: var(--admin-text)">
class="colleague-accounts__field colleague-accounts__field--active"
>
<span class="colleague-accounts__label">
{{ activeField.label }} {{ activeField.label }}
<span class="colleague-accounts__badge">فعال</span> <span class="colleague-accounts__badge">فعال</span>
</span> </span>
<a-input <div class="flex w-full min-w-0 items-center gap-2">
v-model:value="values[activeField.key]" <a-input
:placeholder="activeField.placeholder" v-model:value="values[activeField.key]"
allow-clear :placeholder="activeField.placeholder"
class="colleague-accounts__input" 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> </label>
</section> </section>
</template> </template>
@@ -35,6 +39,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { getConfiguration, setConfiguration } from '@/api/configuration' import { getConfiguration, setConfiguration } from '@/api/configuration'
import type { Const } from '@/model/configuration' import type { Const } from '@/model/configuration'
import { colleagueAccountingCodeHelp } from '@/utils/adminHelpCopy'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { import {
accountingProviderSetting, accountingProviderSetting,
initWalletSettingsFromApi, initWalletSettingsFromApi,
@@ -169,73 +175,14 @@ onMounted(() => {
}) })
</script> </script>
<style scoped lang="less"> <style scoped>
.colleague-accounts { .colleague-accounts {
margin-bottom: 1rem; background: var(--admin-bg-surface, var(--admin-bg-elevated, rgba(28, 28, 34, 0.92)));
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);
} }
.colleague-accounts__badge { .colleague-accounts__badge {
display: inline-flex; @apply inline-flex items-center rounded-full px-1.5 text-10px font-700;
align-items: center;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: rgba(212, 175, 55, 0.18); background: rgba(212, 175, 55, 0.18);
color: #d4af37; color: #d4af37;
font-size: 0.6875rem;
font-weight: 700;
}
.colleague-accounts__input {
width: 100%;
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <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"> <div class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" /> <BaseTable v-bind="tblProps" @register="submit" />
</div> </div>
@@ -14,6 +14,7 @@ import { getColleagueOrderTable } from '@/api/order'
import { getColleagueOrderColumns } from '@/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns' import { getColleagueOrderColumns } from '@/pages/menuPages/colleagueOrders/hooks/colleagueOrderColumns'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable' import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable' import type { TableInput } from '@core/type/baseTable'
import { colleagueOrdersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BasicShowContext title="سفارش شرطی"> <BasicShowContext title="سفارش شرطی" :help="conditionalOrdersPageIntro">
<template #extra> <template #extra>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<BaseButton type="primary" size="small" @click="emits('add')"> <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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/conditionalOrdersColumns' import { cols } from '../hooks/conditionalOrdersColumns'
import { getConditionalOrdersTable } from '@/api/conditionalOrders' import { getConditionalOrdersTable } from '@/api/conditionalOrders'
import { conditionalOrdersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
+2 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0"> <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"> <div class="settings-toolbar">
<a-input <a-input
v-model:value="search" v-model:value="search"
@@ -207,6 +207,7 @@ import { loading } from '@/utils'
import { getEditableConfiguration, getSystemConfiguration, setConfiguration, setSystemConfiguration, refreshSuperAdminSettings } from '@/api/configuration' import { getEditableConfiguration, getSystemConfiguration, setConfiguration, setSystemConfiguration, refreshSuperAdminSettings } from '@/api/configuration'
import SettingValueField from './SettingValueField.vue' import SettingValueField from './SettingValueField.vue'
import SettingCard from './SettingCard.vue' import SettingCard from './SettingCard.vue'
import { configurationPageIntro } from '@/utils/adminHelpCopy'
import { refreshAppModeMenu } from '@/router' import { refreshAppModeMenu } from '@/router'
import { useUserStore } from '@/store/userStore' 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' 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: 'عمومی و برند', title: 'عمومی و برند',
icon: 'vuesax-linear:home-2', icon: 'vuesax-linear:home-2',
match: (k) => 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 k
) )
}, },
@@ -174,7 +174,7 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'عمومی و برند', title: 'عمومی و برند',
icon: 'vuesax-linear:home-2', icon: 'vuesax-linear:home-2',
match: (k) => 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 k
) )
}, },
@@ -292,6 +292,12 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
about_software: 'longtext', about_software: 'longtext',
enamad_url: 'text', enamad_url: 'text',
enamad_logo: '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_service_charge_percentage: 'number',
fee_deduct_from_gold: 'switch', fee_deduct_from_gold: 'switch',
require_zero_main_gold_before_other_buy: '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> = { const KEY_HELP_FALLBACK: Record<string, string> = {
name_fa: 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: banner_enabled:
'روشن: منوی «بنرها» در ادمین و اسلایدر صفحه اصلی اپ مشتری نمایش داده می‌شود. خاموش: مخفی است.', 'روشن: منوی «بنرها» در ادمین باز می‌شود (اسلایدر صفحه اصلی + تصاویر تم مثل بنر اشتراک‌گذاری/کد معرف). خاموش: منو مخفی است.',
permision__enabled: permision__enabled:
'روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.', 'روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.',
open_register: open_register:
@@ -1037,6 +1055,12 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
show_product_card_meta: 'نمایش دسته و تحویل و مقدار روی کارت محصول', show_product_card_meta: 'نمایش دسته و تحویل و مقدار روی کارت محصول',
enamad_url: 'آدرس اینماد', enamad_url: 'آدرس اینماد',
enamad_logo: 'لوگوی اینماد', 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: 'اختلاف پایه خرید/فروش', fee_single: 'اختلاف پایه خرید/فروش',
buy_fee: 'اضافه قیمت خرید', buy_fee: 'اضافه قیمت خرید',
sell_fee: 'کسر قیمت فروش', sell_fee: 'کسر قیمت فروش',
@@ -1268,6 +1292,8 @@ export const SETTING_EDITABLE_KEYS = new Set([
'do_send_liman', 'do_send_liman',
'has_chat', 'has_chat',
'text_about', 'text_rule', 'about_software', 'enamad_url', 'enamad_logo', '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', 'zaarbox_price_key',
'price_channel_enabled', 'price_channel_messengers', 'price_channel_diff', 'price_channel_enabled', 'price_channel_messengers', 'price_channel_diff',
'price_channel_min_seconds', 'price_channel_footer_id', 'price_channel_footer_phone', 'price_channel_min_seconds', 'price_channel_footer_id', 'price_channel_footer_phone',
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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"> <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"> <BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }"> <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 useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable' import type { TableInput } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { customersArchivePageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'charge']) 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" /> <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> </svg>
</div> </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> <p class="text-gray-500 text-sm">لطفاً اطلاعات کارت بانکی فرد مورد نظر را وارد کنید</p>
</div> </div>
@@ -93,6 +99,7 @@ import type { CheckCardRequest } from "@/model/user"
import { checkCard } from "@/api/customer" import { checkCard } from "@/api/customer"
import DatePicker from "@core/components/_sample/components/datePicker.vue"; import DatePicker from "@core/components/_sample/components/datePicker.vue";
import Icon from "@core/components/BaseIcon/src/Icon.vue"; import Icon from "@core/components/BaseIcon/src/Icon.vue";
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
const formState = reactive<CheckCardRequest>({ const formState = reactive<CheckCardRequest>({
// birth_date: '', // birth_date: '',
@@ -27,21 +27,26 @@
برای پاک کردن کد، فیلد را خالی بگذارید و ذخیره کنید. برای پاک کردن کد، فیلد را خالی بگذارید و ذخیره کنید.
</p> </p>
<p <p
v-if="provider === 'tahesab'" v-if="provider === 'tahesab' || provider === 'zarin'"
class="m-0 text-xs" class="m-0 text-xs"
style="color: var(--admin-text-secondary)" style="color: var(--admin-text-secondary)"
> >
بعد از ذخیره کد ته‌حساب، اگر موجودی اپ با مانده ته‌حساب فرق داشته باشد از شما تصمیم گرفته می‌شود. قبل از ذخیره، اگر مانده اپ با حسابداری فرق داشته باشد از شما تأیید گرفته می‌شود؛ بعد اعمال می‌شود.
</p> </p>
</div> </div>
</BasicModal> </BasicModal>
<TahesabMandeDecisionModal <TahesabMandeDecisionModal
v-model:visible="decisionVisible" v-model:visible="decisionVisible"
:source-label="provider === 'zarin' ? 'زرین' : 'ته‌حساب'"
:app-gold="decision?.app_gold" :app-gold="decision?.app_gold"
:app-cash="decision?.app_cash" :app-cash="decision?.app_cash"
:tahesab-gold="decision?.tahesab_gold" :tahesab-gold="decision?.tahesab_gold ?? decision?.zarin_gold"
:tahesab-cash="decision?.tahesab_cash" :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" @decide="onDecide"
/> />
</template> </template>
@@ -52,9 +57,14 @@ import { message } from 'ant-design-vue'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import { updateCustomer } from '@/api/customer' import { updateCustomer } from '@/api/customer'
import { import {
previewCustomerWalletFromTahesab,
syncCustomerWalletFromTahesab, syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult, type TahesabWalletSyncResult,
} from '@/api/tahesab' } from '@/api/tahesab'
import {
previewCustomerWalletFromZarin,
syncCustomerWalletFromZarin,
} from '@/api/zarin'
import type { Customer } from '@/model/customer' import type { Customer } from '@/model/customer'
import type { AccountingProvider } from '@/utils/config' import type { AccountingProvider } from '@/utils/config'
import { displayAccountingCode, normalizeAccountingCode } from '@/utils/accountingCode' import { displayAccountingCode, normalizeAccountingCode } from '@/utils/accountingCode'
@@ -76,7 +86,7 @@ const loading = ref(false)
const code = ref('') const code = ref('')
const decisionVisible = ref(false) const decisionVisible = ref(false)
const decision = ref<TahesabWalletSyncResult['result'] | null>(null) 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({ const visibleProxy = computed({
get: () => props.visible, get: () => props.visible,
@@ -103,47 +113,44 @@ function close() {
visibleProxy.value = false visibleProxy.value = false
} }
async function maybeSyncTahesabWallet(customerId: number, previousCode: string, nextCode: string) { async function saveCode(customerId: number, body: Record<string, string>) {
if (props.provider !== 'tahesab') return await updateCustomer(customerId, body as Customer)
if (!nextCode || nextCode === previousCode) return message.success('کد حسابداری ذخیره شد.')
emit('success')
try { close()
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 onDecide(mode: 'overwrite' | 'keep') { async function applyWalletDecision(customerId: number, mode: 'overwrite' | 'keep') {
const customerId = savedCustomerId.value if (props.provider === 'zarin') {
decisionVisible.value = false return syncCustomerWalletFromZarin(customerId, {
if (!customerId) return
try {
const res = await syncCustomerWalletFromTahesab(customerId, {
overwrite: mode === 'overwrite', overwrite: mode === 'overwrite',
keep: mode === 'keep', keep: mode === 'keep',
ask_if_conflict: false, ask_if_conflict: false,
}) })
if (mode === 'keep') { }
message.info(res.message || 'موجودی اپ حفظ شد') return syncCustomerWalletFromTahesab(customerId, {
} else { overwrite: mode === 'overwrite',
message.success(res.message || 'مانده ته‌حساب روی کیف اعمال شد') 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') emit('success')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally {
loading.value = false
pendingPayload.value = null
} }
} }
@@ -161,13 +168,38 @@ async function submit() {
? { pargar_account_id: normalized } ? { pargar_account_id: normalized }
: { moshtari_code: normalized } : { moshtari_code: normalized }
const needsWalletAsk =
(props.provider === 'tahesab' || props.provider === 'zarin')
&& !!normalized
&& normalized !== previousCode
try { try {
loading.value = true loading.value = true
await updateCustomer(props.customer.id, payload as Customer) if (needsWalletAsk) {
message.success('کد حسابداری ذخیره شد.') try {
emit('success') const preview = props.provider === 'zarin'
close() ? await previewCustomerWalletFromZarin(props.customer.id, normalized)
await maybeSyncTahesabWallet(props.customer.id, previousCode, 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) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@@ -1,6 +1,6 @@
<template> <template>
<div class="min-w-0 w-full max-w-full overflow-x-hidden"> <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"> <div class="min-w-0 w-full max-w-full overflow-x-auto">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }"> <template #action="{ record }">
@@ -110,6 +110,7 @@ import {
pargarTypeLabel pargarTypeLabel
} from '@/pages/menuPages/accounting/utils/pargarDisplay' } from '@/pages/menuPages/accounting/utils/pargarDisplay'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { customerPargarLogsIntro } from '@/utils/adminHelpCopy'
const props = defineProps<{ const props = defineProps<{
customerId?: number customerId?: number
@@ -1,7 +1,7 @@
<template> <template>
<BasicModal <BasicModal
v-model:visible="visibleProxy" v-model:visible="visibleProxy"
:width="isMobile ? '92vw' : '28rem'" :width="isMobile ? '92vw' : '36rem'"
title="تصمیم برای مانده حساب" title="تصمیم برای مانده حساب"
:custom-footer="true" :custom-footer="true"
cancel-text="بعداً" cancel-text="بعداً"
@@ -9,7 +9,7 @@
> >
<div class="flex flex-col gap-3 text-sm" style="color: var(--admin-text)"> <div class="flex flex-col gap-3 text-sm" style="color: var(--admin-text)">
<p class="m-0" style="color: var(--admin-text-secondary)"> <p class="m-0" style="color: var(--admin-text-secondary)">
موجودی اپ با مانده ته‌حساب فرق دارد. کدام را اعمال کنیم؟ موجودی اپ با مانده {{ sourceLabel }} فرق دارد. دو مانده جمع نمی‌شوند؛ یکی را پس از بررسی سندها تأیید کنید.
</p> </p>
<div class="rounded-lg border border-base p-3 space-y-2" style="background: var(--admin-bg-muted)"> <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"> <div class="flex justify-between gap-2">
@@ -17,8 +17,12 @@
<strong dir="ltr">{{ formatGold(appGold) }}</strong> <strong dir="ltr">{{ formatGold(appGold) }}</strong>
</div> </div>
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (طلا)</span> <span style="color: var(--admin-text-secondary)">مانده {{ sourceLabel }} (طلا)</span>
<strong dir="ltr">{{ formatGold(tahesabGold) }}</strong> <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>
<div class="h-px" style="background: var(--admin-border)" /> <div class="h-px" style="background: var(--admin-border)" />
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
@@ -26,13 +30,62 @@
<strong dir="ltr">{{ formatCash(appCash) }}</strong> <strong dir="ltr">{{ formatCash(appCash) }}</strong>
</div> </div>
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<span style="color: var(--admin-text-secondary)">مانده ته‌حساب (ریال)</span> <span style="color: var(--admin-text-secondary)">مانده {{ sourceLabel }} (ریال)</span>
<strong dir="ltr">{{ formatCash(tahesabCash) }}</strong> <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> </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"> <div class="flex flex-col gap-2">
<a-button type="primary" block :loading="loading === 'overwrite'" :disabled="!!loading" @click="choose('overwrite')"> <a-button type="primary" block :loading="loading === 'overwrite'" :disabled="!!loading" @click="choose('overwrite')">
اعمال مانده ته‌حساب روی کیف اعمال مانده {{ sourceLabel }} روی کیف
</a-button> </a-button>
<a-button block :loading="loading === 'keep'" :disabled="!!loading" @click="choose('keep')"> <a-button block :loading="loading === 'keep'" :disabled="!!loading" @click="choose('keep')">
نگه داشتن موجودی فعلی اپ نگه داشتن موجودی فعلی اپ
@@ -48,6 +101,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import type { TahesabPreviewDoc } from '@/api/tahesab'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -55,6 +109,13 @@ const props = defineProps<{
appCash?: number | null appCash?: number | null
tahesabGold?: number | null tahesabGold?: number | null
tahesabCash?: 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<{ const emit = defineEmits<{
@@ -63,6 +124,22 @@ const emit = defineEmits<{
}>() }>()
const loading = ref<'overwrite' | 'keep' | null>(null) 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({ const visibleProxy = computed({
get: () => props.visible, get: () => props.visible,
@@ -86,6 +163,25 @@ function formatCash(v?: number | null) {
return Math.round(Number(v)).toLocaleString('fa-IR') 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() { function close() {
visibleProxy.value = false visibleProxy.value = false
} }
@@ -1,15 +1,15 @@
<template> <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__head">
<div class="tahesab-wallet-sync__icon" aria-hidden="true"> <div class="tahesab-wallet-sync__icon" aria-hidden="true">
<Icon icon="vuesax-linear:refresh-circle" :size="18" color="var(--admin-primary, #10913A)" /> <Icon icon="vuesax-linear:refresh-circle" :size="18" color="var(--admin-primary, #10913A)" />
</div> </div>
<div class="tahesab-wallet-sync__copy min-w-0 flex-1"> <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"> <p class="tahesab-wallet-sync__hint">
{{ {{
canSync canSync
? 'مانده طلا و ریال ته‌حساب را با کیف اپ مقایسه می‌کند؛ اگر تداخل باشد شما تصمیم می‌گیرید' ? 'اول مانده و سندها را می‌بینید؛ بعد تأیید می‌کنید. دو مانده جمع نمی‌شوند'
: 'ابتدا کد حسابداری ته‌حساب را برای این مشتری ثبت کنید' : 'ابتدا کد حسابداری ته‌حساب را برای این مشتری ثبت کنید'
}} }}
</p> </p>
@@ -17,9 +17,9 @@
</div> </div>
<a-button <a-button
type="primary" type="primary"
block
size="small" size="small"
class="tahesab-wallet-sync__btn" class="tahesab-wallet-sync__btn"
:block="layout !== 'row'"
:loading="loading" :loading="loading"
:disabled="!canSync || loading" :disabled="!canSync || loading"
@click="$emit('sync', 'ask')" @click="$emit('sync', 'ask')"
@@ -32,10 +32,14 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from '@core/components/BaseIcon/src/Icon.vue' import Icon from '@core/components/BaseIcon/src/Icon.vue'
defineProps<{ withDefaults(
loading?: boolean defineProps<{
canSync?: boolean loading?: boolean
}>() canSync?: boolean
layout?: 'stack' | 'row'
}>(),
{ layout: 'stack' },
)
defineEmits<{ defineEmits<{
(e: 'sync', mode: 'ask' | 'overwrite' | 'keep'): void (e: 'sync', mode: 'ask' | 'overwrite' | 'keep'): void
@@ -48,6 +52,24 @@ defineEmits<{
background: var(--admin-surface-muted, var(--admin-bg-muted, #f8fafc)); 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 { .tahesab-wallet-sync__head {
@apply mb-2.5 flex items-start gap-2; @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> <template>
<div> <div>
<BasicShowContext title="لیست دارایی ها"> <BasicShowContext title="لیست دارایی ها" :help="customerFundsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }"> <template #action="{ record }">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
@@ -64,6 +64,7 @@ import {
import BasicActionSheet from '@/components/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicActionSheet.vue'
import BasicShowContext from '@/components/BasicShowContext.vue' import BasicShowContext from '@/components/BasicShowContext.vue'
import { toJalali } from '@core/utils/useDataTime' import { toJalali } from '@core/utils/useDataTime'
import { customerFundsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const [assetFundsSubmit] = useBaseTable() const [assetFundsSubmit] = useBaseTable()
@@ -48,7 +48,7 @@
<BaseIcon icon="vuesax-linear:medal-star" Size="16px" /> <BaseIcon icon="vuesax-linear:medal-star" Size="16px" />
</BaseButton> </BaseButton>
</div> </div>
<BasicShowContext title="لیست اقساط"> <BasicShowContext title="لیست اقساط" :help="goldCreditInstallmentsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl" /> <BaseTable v-bind="tblProps" @register="submit" ref="tbl" />
</BasicShowContext> </BasicShowContext>
</div> </div>
@@ -59,6 +59,7 @@ import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable' import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { cols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns' import { cols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns'
import { getCustomerGoldCreditInstallments, type GoldCredit } from '@/api/goldCredit' import { getCustomerGoldCreditInstallments, type GoldCredit } from '@/api/goldCredit'
import { goldCreditInstallmentsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BasicShowContext title="لیست قلک ها"> <BasicShowContext title="لیست قلک ها" :help="customerPiggyFundsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }"> <template #action="{ record }">
<div class="flex items-center justify-center gap-2"> <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 { getPiggyFundsTable, getPiggyFundTransactionsTable } from '@/api/user'
import BasicActionSheet from '@/components/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicActionSheet.vue'
import BasicShowContext from '@/components/BasicShowContext.vue' import BasicShowContext from '@/components/BasicShowContext.vue'
import { customerPiggyFundsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const [txSubmit] = useBaseTable() const [txSubmit] = useBaseTable()
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BasicShowContext title="لیست معرفی کرده ها"> <BasicShowContext title="لیست معرفی کرده ها" :help="customerReferralsIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
</BaseTable> </BaseTable>
</BasicShowContext> </BasicShowContext>
@@ -28,6 +28,7 @@ import {toJalaliNoTime} from '@core/utils/useDataTime'
import type {Customer} from '@/model/customer' import type {Customer} from '@/model/customer'
import {onMounted} from 'vue' import {onMounted} from 'vue'
import {getReferralsTable} from "@/api/user"; import {getReferralsTable} from "@/api/user";
import { customerReferralsIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست مشتریان"> <BasicShowContext title="لیست مشتریان" :help="customersPageIntro">
<template #extra> <template #extra>
<BaseButton <BaseButton
v-if="isMobile" v-if="isMobile"
@@ -273,6 +273,7 @@ import {
initWalletSettingsFromApi initWalletSettingsFromApi
} from '@/utils/config' } from '@/utils/config'
import { displayAccountingCode, isValidAccountingCode } from '@/utils/accountingCode' import { displayAccountingCode, isValidAccountingCode } from '@/utils/accountingCode'
import { customersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'chargeWallet']) const emits = defineEmits(['delete', 'edit', 'add', 'info', 'chargeWallet'])
+107 -6
View File
@@ -203,6 +203,12 @@
:can-sync="tahesabWalletCanSync" :can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet" @sync="syncTahesabWallet"
/> />
<ZarinWalletSyncAction
v-if="zarinWalletSyncVisible"
:loading="zarinSyncLoading"
:can-sync="zarinWalletCanSync"
@sync="syncZarinWallet"
/>
<CustomerWalletBalancePanel <CustomerWalletBalancePanel
:key="`wallet-mobile-${walletPanelKey}`" :key="`wallet-mobile-${walletPanelKey}`"
ref="walletPanelMobile" ref="walletPanelMobile"
@@ -230,6 +236,12 @@
:can-sync="tahesabWalletCanSync" :can-sync="tahesabWalletCanSync"
@sync="syncTahesabWallet" @sync="syncTahesabWallet"
/> />
<ZarinWalletSyncAction
v-if="zarinWalletSyncVisible"
:loading="zarinSyncLoading"
:can-sync="zarinWalletCanSync"
@sync="syncZarinWallet"
/>
<CustomerWalletBalancePanel <CustomerWalletBalancePanel
:key="`wallet-desktop-${walletPanelKey}`" :key="`wallet-desktop-${walletPanelKey}`"
ref="walletPanelDesktop" ref="walletPanelDesktop"
@@ -262,6 +274,12 @@
<CustomerPargarLogsTable ref="pargarLogsTable" :customer-id="userInfo?.id" /> <CustomerPargarLogsTable ref="pargarLogsTable" :customer-id="userInfo?.id" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="tahesabDocsVisible" key="اسناد ته‌حساب" tab="اسناد ته‌حساب"> <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" /> <CustomerTahesabDocsTable ref="tahesabDocsTable" :customer-id="userInfo?.id" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="zarinDocsVisible" key="اسناد زرین" tab="اسناد زرین"> <a-tab-pane v-if="zarinDocsVisible" key="اسناد زرین" tab="اسناد زرین">
@@ -289,10 +307,15 @@
/> />
<TahesabMandeDecisionModal <TahesabMandeDecisionModal
v-model:visible="tahesabDecisionVisible" v-model:visible="tahesabDecisionVisible"
:source-label="walletDecisionLabel"
:app-gold="tahesabDecision?.app_gold" :app-gold="tahesabDecision?.app_gold"
:app-cash="tahesabDecision?.app_cash" :app-cash="tahesabDecision?.app_cash"
:tahesab-gold="tahesabDecision?.tahesab_gold" :tahesab-gold="tahesabDecision?.tahesab_gold ?? tahesabDecision?.zarin_gold"
:tahesab-cash="tahesabDecision?.tahesab_cash" :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" @decide="onTahesabMandeDecide"
/> />
<BasicInfoModal <BasicInfoModal
@@ -359,11 +382,17 @@ import { displayAccountingCode, isValidAccountingCode } from '@/utils/accounting
import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue' import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue'
import { syncCustomerWalletFromPargar, type PargarSyncWalletResult } from '@/api/pargar' import { syncCustomerWalletFromPargar, type PargarSyncWalletResult } from '@/api/pargar'
import { import {
previewCustomerWalletFromTahesab,
syncCustomerWalletFromTahesab, syncCustomerWalletFromTahesab,
type TahesabWalletSyncResult, type TahesabWalletSyncResult,
} from '@/api/tahesab' } from '@/api/tahesab'
import {
syncCustomerWalletFromZarin,
type ZarinWalletSyncResult,
} from '@/api/zarin'
import PargarWalletSyncAction from './components/PargarWalletSyncAction.vue' import PargarWalletSyncAction from './components/PargarWalletSyncAction.vue'
import TahesabWalletSyncAction from './components/TahesabWalletSyncAction.vue' import TahesabWalletSyncAction from './components/TahesabWalletSyncAction.vue'
import ZarinWalletSyncAction from './components/ZarinWalletSyncAction.vue'
import TahesabMandeDecisionModal from './components/TahesabMandeDecisionModal.vue' import TahesabMandeDecisionModal from './components/TahesabMandeDecisionModal.vue'
const fnameHelp: DashboardHelp = { const fnameHelp: DashboardHelp = {
@@ -761,8 +790,11 @@ const accountingCodeValue = computed(() => {
const pargarSyncLoading = ref(false) const pargarSyncLoading = ref(false)
const tahesabSyncLoading = ref(false) const tahesabSyncLoading = ref(false)
const zarinSyncLoading = ref(false)
const tahesabDecisionVisible = 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 walletPanelDesktop = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelMobile = ref<{ reload?: () => void | Promise<void> } | null>(null) const walletPanelMobile = ref<{ reload?: () => void | Promise<void> } | null>(null)
const walletPanelKey = ref(0) const walletPanelKey = ref(0)
@@ -776,6 +808,10 @@ const tahesabWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'tahesab' () => accountingProviderSetting.value === 'tahesab'
) )
const zarinWalletSyncVisible = computed(
() => accountingProviderSetting.value === 'zarin'
)
const tahesabDocsVisible = computed(() => accountingProviderSetting.value === 'tahesab') const tahesabDocsVisible = computed(() => accountingProviderSetting.value === 'tahesab')
const zarinDocsVisible = computed(() => accountingProviderSetting.value === 'zarin') const zarinDocsVisible = computed(() => accountingProviderSetting.value === 'zarin')
@@ -787,6 +823,10 @@ const tahesabWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.moshtari_code) isValidAccountingCode(userInfo.value?.moshtari_code)
) )
const zarinWalletCanSync = computed(() =>
isValidAccountingCode(userInfo.value?.moshtari_code)
)
async function refreshWalletPanels() { async function refreshWalletPanels() {
await nextTick() await nextTick()
walletPanelKey.value += 1 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 tahesabDecision.value = result ?? null
tahesabDecisionVisible.value = true tahesabDecisionVisible.value = true
} }
@@ -806,14 +847,28 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
if (!userInfo.value?.id || !tahesabWalletCanSync.value || tahesabSyncLoading.value) return if (!userInfo.value?.id || !tahesabWalletCanSync.value || tahesabSyncLoading.value) return
tahesabSyncLoading.value = true tahesabSyncLoading.value = true
try { 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, { const res = await syncCustomerWalletFromTahesab(userInfo.value.id, {
ask_if_conflict: mode === 'ask', ask_if_conflict: false,
overwrite: mode === 'overwrite', overwrite: mode === 'overwrite',
keep: mode === 'keep', keep: mode === 'keep',
}) })
if (res.needs_decision || res.result?.needs_decision) { if (res.needs_decision || res.result?.needs_decision) {
openTahesabDecision(res.result) openTahesabDecision(res.result, 'tahesab')
return return
} }
@@ -846,9 +901,55 @@ async function syncTahesabWallet(mode: 'ask' | 'overwrite' | 'keep' = 'ask') {
async function onTahesabMandeDecide(mode: 'overwrite' | 'keep') { async function onTahesabMandeDecide(mode: 'overwrite' | 'keep') {
tahesabDecisionVisible.value = false tahesabDecisionVisible.value = false
if (walletDecisionProvider.value === 'zarin') {
await syncZarinWallet(mode)
return
}
await syncTahesabWallet(mode) 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) { async function syncPargarWallet(force = false) {
if (!userInfo.value?.id || !pargarWalletCanSync.value || pargarSyncLoading.value) return if (!userInfo.value?.id || !pargarWalletCanSync.value || pargarSyncLoading.value) return
pargarSyncLoading.value = true pargarSyncLoading.value = true
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 w-full"> <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-tabs v-model:activeKey="activeTab" class="gold-credit-tabs h-full" destroy-inactive-tab-pane>
<a-tab-pane key="requests" tab="درخواست‌ها"> <a-tab-pane key="requests" tab="درخواست‌ها">
<GoldCreditRequestsTab /> <GoldCreditRequestsTab />
@@ -17,6 +17,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import GoldCreditRequestsTab from './requests/index.vue' import GoldCreditRequestsTab from './requests/index.vue'
import GoldCreditActiveTab from './active/index.vue' import GoldCreditActiveTab from './active/index.vue'
import { goldCreditPageIntro } from '@/utils/adminHelpCopy'
const activeTab = ref('requests') const activeTab = ref('requests')
</script> </script>
@@ -3,7 +3,10 @@
<div class="online-page__hero shrink-0 mb-4"> <div class="online-page__hero shrink-0 mb-4">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="online-page__count"> <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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicActionSheet.vue'
import { isMobile } from '@core/utils/is' 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 { MODAL_ABOVE_ACTION_SHEET_Z_INDEX } from '@/constants/overlayZIndex'
import { inputLenRules, inputRules } from '@/utils/inputRules' import { inputLenRules, inputRules } from '@/utils/inputRules'
import CustomerMobileDetailSheet from '@/pages/menuPages/customers/base/components/CustomerMobileDetailSheet.vue' import CustomerMobileDetailSheet from '@/pages/menuPages/customers/base/components/CustomerMobileDetailSheet.vue'
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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> <template v-if="isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')"> <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 { getSpecialPricePackageColumns } from '../hooks/specialPriceColumns'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { ref } from 'vue' import { ref } from 'vue'
import { specialPricePageIntro } from '@/utils/adminHelpCopy'
const emits = defineEmits<{ const emits = defineEmits<{
(e: 'add'): void (e: 'add'): void
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BasicShowContext title="لیست تخفیفات"> <BasicShowContext title="لیست تخفیفات" :help="discountPageIntro">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader> <template #extraHeader>
<BaseButton type="primary" size="small" class="flex" @click="emits('add')"> <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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/discountColumns' import { cols } from '../hooks/discountColumns'
import { getDiscountTable } from '@/api/discount' import { getDiscountTable } from '@/api/discount'
import { discountPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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> <template v-if="!customerId && isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:receipt-add" @click="emits('add', 'gift')"> <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 GiftCardMobileCardList from './GiftCardMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { isStoreApp } from '@/utils/config' import { isStoreApp } from '@/utils/config'
import { giftCardsPageIntro } from '@/utils/adminHelpCopy'
interface Prop { interface Prop {
customerId?: number customerId?: number
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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> <template v-if="!customerId && isMobile" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:receipt-add" @click="emits('add')"> <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 { getOrderTable } from '@/api/order'
import OrderMobileCardList from './OrderMobileCardList.vue' import OrderMobileCardList from './OrderMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { ordersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,9 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست برداشت فیزیکی"> <BasicShowContext title="لیست برداشت فیزیکی" :help="physicalWithdrawPageIntro">
<template #extra>
<AdminFieldHelp :text="physicalWithdrawPageIntro" />
</template>
<div class="flex h-full min-h-0 flex-col"> <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"> <BaseTable class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader> <template #extraHeader>
@@ -89,7 +86,6 @@ import { updateSts } from '@/api/wallet'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { Withdraw } from '@/model/wallet' import type { Withdraw } from '@/model/wallet'
import { status as physicalStatusMap } from '@/pages/menuPages/withdraw/information/hooks/status' import { status as physicalStatusMap } from '@/pages/menuPages/withdraw/information/hooks/status'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import PhysicalWithdrawAdjustModal from './PhysicalWithdrawAdjustModal.vue' import PhysicalWithdrawAdjustModal from './PhysicalWithdrawAdjustModal.vue'
import { import {
physicalWithdrawApproveHelp, physicalWithdrawApproveHelp,
@@ -1,14 +1,11 @@
<template> <template>
<div> <div>
<BasicShowContext title="هشدار قیمت"> <BasicShowContext title="هشدار قیمت" :help="priceTriggersPageIntro">
<template #extra> <template #extra>
<div class="flex items-center gap-2"> <BaseButton type="primary" size="small" @click="emits('add')">
<AdminFieldHelp :text="priceTriggersPageIntro" /> <span>افزودن هشدار</span>
<BaseButton type="primary" size="small" @click="emits('add')"> <BaseIcon icon="vuesax-linear:add" size="16" />
<span>افزودن هشدار</span> </BaseButton>
<BaseIcon icon="vuesax-linear:add" size="16" />
</BaseButton>
</div>
</template> </template>
<BaseTable v-bind="tblProps" @register="submit" ref="tbl"> <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 Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/priceTriggersColumns' import { cols } from '../hooks/priceTriggersColumns'
import { getPriceTriggersTable } from '@/api/priceTriggers' import { getPriceTriggersTable } from '@/api/priceTriggers'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { priceTriggersPageIntro } from '@/utils/adminHelpCopy' import { priceTriggersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
@@ -1,10 +1,8 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="تاریخچه قیمت"> <BasicShowContext title="تاریخچه قیمت" :help="pricingPageIntro">
<template #extra> <template #extra>
<div class="flex items-center gap-2"> <BaseButton
<AdminFieldHelp :text="pricingPageIntro" />
<BaseButton
v-if="isMobile" v-if="isMobile"
type="primary" type="primary"
size="small" size="small"
@@ -14,7 +12,6 @@
> >
به‌روزرسانی قیمت به‌روزرسانی قیمت
</BaseButton> </BaseButton>
</div>
</template> </template>
<div class="flex h-full min-h-0 flex-col"> <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 { usePrefix } from '@core/composable/usePrefix'
import { getPriceSyncLogTable } from '@/api/price' import { getPriceSyncLogTable } from '@/api/price'
import PriceMobileCardList from './PriceMobileCardList.vue' import PriceMobileCardList from './PriceMobileCardList.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { pricingPageIntro } from '@/utils/adminHelpCopy' import { pricingPageIntro } from '@/utils/adminHelpCopy'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import type { PriceSyncLog } from '@/model/priceSyncLog' import type { PriceSyncLog } from '@/model/priceSyncLog'
@@ -36,7 +36,7 @@
</div> </div>
</div> </div>
<BasicShowContext title="لیست محصولات" class="min-h-0 flex-1"> <BasicShowContext title="لیست محصولات" class="min-h-0 flex-1" :help="productsPageIntro">
<template #extra> <template #extra>
<BaseButton <BaseButton
v-if="isMobile" v-if="isMobile"
@@ -148,6 +148,7 @@ import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable' import type { TableInput } from '@core/type/baseTable'
import type { Product } from '@/model/product' import type { Product } from '@/model/product'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { productsPageIntro } from '@/utils/adminHelpCopy'
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
@@ -1,6 +1,10 @@
<template> <template>
<div class="category-page flex h-full min-h-0 flex-col gap-4 p-4"> <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"> <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 :loading="syncing" @click="handleSync">
همگام‌سازی از محصولات همگام‌سازی از محصولات
</BaseButton> </BaseButton>
@@ -65,6 +69,8 @@ import { debounce } from 'lodash'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { getCategories, syncCategoriesFromProducts, updateCategory } from '@/api/category' import { getCategories, syncCategoriesFromProducts, updateCategory } from '@/api/category'
import type { Category } from '@/model/category' import type { Category } from '@/model/category'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import { productsCategoriesPageIntro } from '@/utils/adminHelpCopy'
const loading = ref(false) const loading = ref(false)
const syncing = ref(false) const syncing = ref(false)
+2 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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> <template v-if="groupSmsEnabled && !mobile && isMobileScreen" #extra>
<BaseButton type="primary" size="small" icon="vuesax-linear:sms" @click="emits('add')"> <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 SmsMobileCardList from './SmsMobileCardList.vue'
import { isMobile as isMobileScreen } from '@core/utils/is' import { isMobile as isMobileScreen } from '@core/utils/is'
import { groupSmsEnabledSetting, initWalletSettingsFromApi } from '@/utils/config' import { groupSmsEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
import { smsPageIntro } from '@/utils/adminHelpCopy'
interface Prop { interface Prop {
mobile?: number | string mobile?: number | string
+6 -1
View File
@@ -2,7 +2,10 @@
<div class="sms-panel-page h-full min-h-0 w-full min-w-0"> <div class="sms-panel-page 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"> <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"> <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> <p class="sms-panel-subtitle mt-1 text-sm">مدیریت کیف پول، تعرفه و گزارش ارسال پیامک</p>
</header> </header>
@@ -628,6 +631,8 @@ import BasicModal from '@/components/BasicModal.vue'
import BaseDatePicker from '@core/components/BaseDatePicker' import BaseDatePicker from '@core/components/BaseDatePicker'
import BaseButton from '@core/components/BaseButton' import BaseButton from '@core/components/BaseButton'
import Icon from '@core/components/BaseIcon/src/Icon.vue' 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 SmsPanelLogsTable from './components/SmsPanelLogsTable.vue'
import SmsPanelLedgerTable from './components/SmsPanelLedgerTable.vue' import SmsPanelLedgerTable from './components/SmsPanelLedgerTable.vue'
import { import {
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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"> <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"> <BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<!-- <template #extraHeader> <!-- <template #extraHeader>
@@ -75,6 +75,7 @@ import {getWalletTable} from '@/api/wallet'
import { getTransactionTable } from '@/api/transaction' import { getTransactionTable } from '@/api/transaction'
import TransactionMobileCardList from './TransactionMobileCardList.vue' import TransactionMobileCardList from './TransactionMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { transactionsPageIntro } from '@/utils/adminHelpCopy'
interface Prop { interface Prop {
customerId?: number customerId?: number
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست مدیران"> <BasicShowContext title="لیست مدیران" :help="usersPageIntro">
<template #extra> <template #extra>
<BaseButton <BaseButton
v-if="isMobile" v-if="isMobile"
@@ -65,6 +65,7 @@ import { getUserTable } from '@/api/user'
import { cols } from '../hooks/usersColumns' import { cols } from '../hooks/usersColumns'
import ManagerMobileCardList from './ManagerMobileCardList.vue' import ManagerMobileCardList from './ManagerMobileCardList.vue'
import { isMobile } from '@core/utils/is' import { isMobile } from '@core/utils/is'
import { usersPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0"> <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="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 class="hidden shrink-0 md:grid md:grid-cols-1 md:gap-3 lg:grid-cols-2 xl:grid-cols-3">
<div <div
@@ -81,6 +81,7 @@ import { isMobile } from '@core/utils/is'
import { totalMetalWalletTitle } from '@/utils/adminMetalLabels' import { totalMetalWalletTitle } from '@/utils/adminMetalLabels'
import type { WalletTableSums } from '@/model/wallet' import type { WalletTableSums } from '@/model/wallet'
import { formatMetalDisplayValue, metalDisplayUnit } from '@/utils/metalWeight' import { formatMetalDisplayValue, metalDisplayUnit } from '@/utils/metalWeight'
import { walletPageIntro } from '@/utils/adminHelpCopy'
const [submit, reload] = useBaseTable() const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null) const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -1,6 +1,6 @@
<template> <template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden"> <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"> <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"> <BaseTable v-if="!isMobile" :key="flowType" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader> <template #extraHeader>
@@ -241,6 +241,7 @@ onMounted(() => {
import { status } from '@/pages/menuPages/withdraw/information/hooks/status' import { status } from '@/pages/menuPages/withdraw/information/hooks/status'
import {toNumber} from "lodash"; import {toNumber} from "lodash";
import { withdrawPageIntro } from '@/utils/adminHelpCopy'
const withdrawId = ref(null) const withdrawId = ref(null)
const sts = ref() const sts = ref()
const statusList = computed(() => { const statusList = computed(() => {
+6 -2
View File
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { importFromTahesab, type TahesabOnlyCustomer } from '@/api/tahesab' import { importFromTahesab, type TahesabOnlyCustomer } from '@/api/tahesab'
import { importFromPargar, type PargarOnlyCustomer } from '@/api/pargar' import { importFromPargar, type PargarOnlyCustomer } from '@/api/pargar'
import { importFromZarin, type ZarinOnlyCustomer } from '@/api/zarin'
export interface ImportError { export interface ImportError {
code: number code: number
@@ -10,12 +11,12 @@ export interface ImportError {
} }
export type ImportStatus = 'idle' | 'running' | 'done' | 'canceled' 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' export type ImportOutcome = 'running' | 'success' | 'error' | 'partial' | 'canceled'
type ImportCustomer = TahesabOnlyCustomer | PargarOnlyCustomer type ImportCustomer = TahesabOnlyCustomer | PargarOnlyCustomer | ZarinOnlyCustomer
/** تعداد مشتری در هر درخواست (برای جلوگیری از محدودیت نرخ سرور) */ /** تعداد مشتری در هر درخواست (برای جلوگیری از محدودیت نرخ سرور) */
const CHUNK_SIZE = 50 const CHUNK_SIZE = 50
@@ -120,6 +121,9 @@ export const useCustomerImportStore = defineStore('customerImport', () => {
if (provider.value === 'pargar') { if (provider.value === 'pargar') {
return await importFromPargar(chunk as PargarOnlyCustomer[]) return await importFromPargar(chunk as PargarOnlyCustomer[])
} }
if (provider.value === 'zarin') {
return await importFromZarin(chunk as ZarinOnlyCustomer[])
}
return await importFromTahesab(chunk as TahesabOnlyCustomer[]) return await importFromTahesab(chunk as TahesabOnlyCustomer[])
} catch (e) { } catch (e) {
if (attempt < 2 && !cancelRequested) { if (attempt < 2 && !cancelRequested) {
+117
View File
@@ -170,3 +170,120 @@ export const automationStatusHelp =
export const tradingHoursPageIntro = export const tradingHoursPageIntro =
'ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری. می‌توانید برای همه روزها یک ساعت بگذارید یا برای هر روز هفته جداگانه تنظیم کنید.' 'ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری. می‌توانید برای همه روزها یک ساعت بگذارید یا برای هر روز هفته جداگانه تنظیم کنید.'
export const customersPageIntro =
'لیست مشتریان ثبت‌شده در اپ. از اینجا می‌توانید مشتری جدید بسازید، ویرایش کنید، موجودی و سفارش‌ها را ببینید، یا مشتری را بایگانی کنید.'
export const customersArchivePageIntro =
'مشتریانی که بایگانی شده‌اند اینجا هستند؛ در اپ فعال نیستند. می‌توانید دوباره از بایگانی خارج‌شان کنید.'
export const customersOnlinePageIntro =
'مشتریانی که الان اپ را باز دارند. با بستن اپ از لیست حذف می‌شوند. برای جزئیات روی کارت بزنید.'
export const goldCreditPageIntro =
'اعتبار طلایی مشتریان: درخواست‌های جدید و اعتبارهای فعال. از تب‌ها بین درخواست و فعال جابه‌جا شوید.'
export const goldCreditInstallmentsIntro =
'اقساط مربوط به اعتبار طلایی همین مشتری. وضعیت پرداخت هر قسط را اینجا می‌بینید.'
export const specialPricePageIntro =
'پکیج قیمت ویژه برای گروهی از مشتریان. با پکیج می‌توانید خرید/فروش را با قیمت یا کارمزد متفاوت از بقیه تنظیم کنید.'
export const productsPageIntro =
'محصولات قابل معامله در اپ. افزودن، ویرایش، قیمت‌گذاری، موجودی و بایگانی از همین‌جا انجام می‌شود.'
export const productsCategoriesPageIntro =
'دسته‌بندی محصولات برای فیلتر تب‌های اپ. ترتیب را با کشیدن عوض کنید؛ «همگام‌سازی از محصولات» دسته‌های گم‌شده را از روی محصولات می‌سازد.'
export const ordersPageIntro =
'سفارش‌های خرید و فروش مشتریان. می‌توانید سفارش جدید ثبت کنید یا جزئیات و وضعیت هر سفارش را ببینید.'
export const conditionalOrdersPageIntro =
'سفارش شرطی: وقتی قیمت به هدف برسد سیستم خودکار سفارش می‌زند. فعال/غیرفعال و ویرایش از همین لیست.'
export const colleagueOrdersPageIntro =
'تراکنش‌های پوشش‌شده با اپ همکار. برای ثبت درست سند حسابداری، کد حساب همکار را هم در همین صفحه تنظیم کنید.'
export const colleagueAccountingCodeHelp =
'کدی که سند خرید/فروش پوشش‌شدهٔ همکار روی آن در سیستم حسابداری فعال ثبت می‌شود. فقط فیلد مربوط به حسابداری فعال را پر کنید.'
export const transactionsPageIntro =
'گزارش تراکنش‌های کیف پول و عملیات مالی مشتریان. برای بررسی جزئیات هر ردیف را باز کنید.'
export const walletPageIntro =
'موجودی کیف‌های مشتریان (طلا، ریال، محصول، قلک). خلاصه و جزئیات هر کیف را اینجا می‌بینید.'
export const withdrawPageIntro =
'درخواست‌های واریز و برداشت ریالی مشتریان. تایید، رد یا تعیین وزن (در صورت نیاز) از همین لیست.'
export const cardsPageIntro =
'حساب‌ها و کارت‌های بانکی ثبت‌شده برای واریز/برداشت. افزودن، ویرایش یا حذف از همین‌جا.'
export const giftCardsPageIntro =
'ساخت و مدیریت کارت هدیه (ریالی یا طلا). هر پویش چند کد یکتا می‌سازد؛ وضعیت مصرف و انقضا را اینجا ببینید.'
export const discountPageIntro =
'پویش‌های کد تخفیف. درصد، تعداد کد و تاریخ انقضا را تنظیم کنید؛ هر کد معمولاً یک‌بار مصرف است.'
export const smsPageIntro =
'تاریخچه پیامک‌های ارسال‌شده به مشتریان. در صورت فعال بودن پیامک گروهی، از همین‌جا هم می‌توانید پیامک بفرستید.'
export const smsPanelPageIntro =
'پنل شارژ و وضعیت سرویس پیامک. موجودی، خرید بسته و تنظیمات ارسال پیامک در این صفحه است.'
export const appNotificationsPageIntro =
'اعلان ادمین را دستی می‌فرستید؛ اعلان سیستمی خودکار برای خرید/فروش/واریز/برداشت ساخته می‌شود. از تب‌ها برای جدا کردن لیست استفاده کنید.'
export const automationsPageIntro =
'قوانین پیام خودکار (زمان‌دار، رویداد یا قیمت). مخاطب، متن، کانال پیامک/اعلان و سقف ارسال را اینجا تنظیم کنید.'
export const usersPageIntro =
'مدیران پنل ادمین. افزودن مدیر، ویرایش دسترسی و غیرفعال کردن از همین لیست.'
export const captureSitesPageIntro =
'سایت‌های منبع قیمت برای ری‌گیری. روشن/خاموش کردن ری‌گیری و مدیریت آدرس‌ها از همین صفحه.'
export const configurationPageIntro =
'تنظیمات کلی سامانه (کلید–مقدار). با جستجو و فیلتر نوع فیلد، مورد را پیدا کنید؛ بعد از تغییر ذخیره کنید. روی آیکون i کنار هر تنظیم توضیح همان فیلد را ببینید.'
export const accountingReportsPageIntro =
'گزارش‌های حسابداری داخلی پنل. برای اسناد و همگام‌سازی مشتریان از تب‌های ته‌حساب / زرین / پرگار استفاده کنید.'
export const accountingTahesabDocsIntro =
'اسناد ارسال‌شده یا در صف ته‌حساب. می‌توانید وضعیت، خطا و جزئیات هر سند را ببینید و در صورت نیاز همگام کنید.'
export const accountingZarinDocsIntro =
'اسناد ارسال‌شده یا در صف زرین. وضعیت، خطا و جزئیات هر سند را اینجا پیگیری کنید.'
export const accountingPargarDocsIntro =
'اسناد پرگار. وضعیت ارسال، خطا و جزئیات هر سند را اینجا ببینید.'
export const accountingTahesabCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد ته‌حساب. تب «پنل ← ته‌حساب» کسانی که فقط در پنل‌اند؛ تب برعکس کسانی که فقط در ته‌حساب‌اند.'
export const accountingZarinCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد زرین. مشتریان بدون لینک را از دو طرف پیدا و ثبت کنید.'
export const accountingPargarCustomerSyncIntro =
'لینک کردن مشتریان پنل با کد پرگار. مشتریان بدون لینک را از دو طرف پیدا و ثبت کنید.'
export const accountingTahesabBalanceSyncIntro =
'جدول مشتریان لینک‌شده ماندهٔ کیف را کنار ماندهٔ ته‌حساب و سندها نشان می‌دهد. همگام تکی اول بررسی و تأیید است. «همگام‌سازی» همه فقط کیف خالی را می‌نشاند و تداخل را نگه می‌دارد؛ «اعمال اجباری» مانده ته‌حساب را برای همه جایگزین می‌کند. دو مانده جمع نمی‌شوند.'
export const accountingZarinBalanceSyncIntro =
'مانده طلا و ریال زرین را روی کیف اصلی مشتریان لینک‌شده می‌نشاند. همگام‌سازی دستی است؛ تداخل برای تصمیم شما می‌ماند. دو مانده با هم جمع نمی‌شوند.'
export const accountingPargarBalanceSyncIntro =
'مانده پرگار را روی کیف مشتریان لینک‌شده می‌نشاند. قبل از اجرا مطمئن شوید کد پرگار مشتریان درست است.'
export const customerFundsIntro =
'دارایی‌های مرتبط با همین مشتری (محصول و موجودی). جزئیات و تراکنش هر دارایی را از اکشن‌ها باز کنید.'
export const customerPiggyFundsIntro =
'قلک‌های همین مشتری و موجودی هرکدام.'
export const customerReferralsIntro =
'مشتریانی که با کد معرف همین نفر ثبت‌نام کرده‌اند.'
export const customerPargarLogsIntro =
'لاگ و اسناد پرگار مربوط به همین مشتری.'
+7
View File
@@ -22,6 +22,13 @@ export function hasAppUiConfig(): boolean {
return Boolean(cfg) 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 storeModeFromSettings: boolean | null = null
let settingsBootstrapped = false let settingsBootstrapped = false