2026-09-04 22:00:23 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { onMounted, reactive, ref } from 'vue';
|
2026-09-05 09:33:12 +03:30
|
|
|
|
import { RouterLink } from 'vue-router';
|
2026-09-04 22:00:23 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
CreateTahesabConnectionRequest,
|
|
|
|
|
|
TahesabConnectionDto,
|
|
|
|
|
|
UpdateTahesabConnectionRequest,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-09-07 11:49:54 +03:30
|
|
|
|
import { brand } from '@mento/shared';
|
2026-09-12 01:33:07 +03:30
|
|
|
|
import {
|
|
|
|
|
|
UiBadge,
|
|
|
|
|
|
UiButton,
|
|
|
|
|
|
UiCard,
|
|
|
|
|
|
UiField,
|
|
|
|
|
|
UiInput,
|
|
|
|
|
|
UiPageHeader,
|
|
|
|
|
|
UiSelect,
|
|
|
|
|
|
} from '@/components/ui';
|
|
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const ok = ref('');
|
|
|
|
|
|
const items = ref<TahesabConnectionDto[]>([]);
|
|
|
|
|
|
const editingId = ref<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const form = reactive({
|
|
|
|
|
|
label: '',
|
|
|
|
|
|
baseUrl: '',
|
|
|
|
|
|
dbName: 'DB',
|
|
|
|
|
|
token: '',
|
|
|
|
|
|
isDefault: false,
|
|
|
|
|
|
allowInsecureTls: false,
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const sync = reactive({
|
|
|
|
|
|
connectionId: '',
|
|
|
|
|
|
phone: '',
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
moshtariCode: '' as string | number,
|
2026-09-05 10:30:54 +03:30
|
|
|
|
fromCode: '',
|
|
|
|
|
|
toCode: '',
|
2026-09-04 22:00:23 +03:30
|
|
|
|
});
|
2026-09-05 09:33:12 +03:30
|
|
|
|
const syncBusy = ref(false);
|
|
|
|
|
|
type SyncResult = {
|
|
|
|
|
|
moshtariCode: number | null;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
tel: string;
|
|
|
|
|
|
alreadyLinked?: boolean;
|
|
|
|
|
|
personId?: string | null;
|
|
|
|
|
|
personName?: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
const syncResults = ref<SyncResult[]>([]);
|
|
|
|
|
|
|
2026-09-21 21:25:34 +03:30
|
|
|
|
const productSync = reactive({
|
|
|
|
|
|
connectionId: '',
|
|
|
|
|
|
q: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
const productSyncBusy = ref(false);
|
|
|
|
|
|
type ProductSyncResult = {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
ayar: number | null;
|
|
|
|
|
|
vaznSabet: number | null;
|
|
|
|
|
|
lastSanad: string;
|
|
|
|
|
|
alreadyImported?: boolean;
|
|
|
|
|
|
productId?: string | null;
|
|
|
|
|
|
productName?: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
const productSyncResults = ref<ProductSyncResult[]>([]);
|
|
|
|
|
|
const productCategoryLabel = ref('کارساخته');
|
|
|
|
|
|
|
2026-09-05 09:33:12 +03:30
|
|
|
|
function pendingImportCodes() {
|
|
|
|
|
|
return syncResults.value
|
|
|
|
|
|
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
|
|
|
|
|
|
.map((r) => r.moshtariCode as number);
|
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
2026-09-21 21:25:34 +03:30
|
|
|
|
function pendingProductNames() {
|
|
|
|
|
|
return productSyncResults.value
|
|
|
|
|
|
.filter((r) => !r.alreadyImported)
|
|
|
|
|
|
.map((r) => r.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 09:42:08 +03:30
|
|
|
|
function apiErr(e: unknown, fallback: string) {
|
|
|
|
|
|
if (!axios.isAxiosError(e)) return fallback;
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
if (Array.isArray(msg)) return msg.join('، ') || fallback;
|
|
|
|
|
|
if (typeof msg === 'string' && msg.trim()) return msg;
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 22:00:23 +03:30
|
|
|
|
function resetForm() {
|
|
|
|
|
|
editingId.value = null;
|
|
|
|
|
|
form.label = '';
|
|
|
|
|
|
form.baseUrl = '';
|
|
|
|
|
|
form.dbName = 'DB';
|
|
|
|
|
|
form.token = '';
|
|
|
|
|
|
form.isDefault = items.value.length === 0;
|
|
|
|
|
|
form.allowInsecureTls = false;
|
|
|
|
|
|
form.enabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 09:52:27 +03:30
|
|
|
|
function looksLikePrivateHttps(url: string) {
|
|
|
|
|
|
return /^https:\/\/(\d{1,3}\.){3}\d{1,3}(:\d+)?/i.test(url.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isTlsError(text: string | null | undefined) {
|
|
|
|
|
|
if (!text) return false;
|
|
|
|
|
|
return /self-signed|certificate|UNABLE_TO_VERIFY|CERT_|گواهی/i.test(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onBaseUrlBlur() {
|
|
|
|
|
|
if (!editingId.value && looksLikePrivateHttps(form.baseUrl)) {
|
|
|
|
|
|
form.allowInsecureTls = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 22:00:23 +03:30
|
|
|
|
function startEdit(row: TahesabConnectionDto) {
|
|
|
|
|
|
editingId.value = row.id;
|
|
|
|
|
|
form.label = row.label;
|
|
|
|
|
|
form.baseUrl = row.baseUrl;
|
|
|
|
|
|
form.dbName = row.dbName;
|
|
|
|
|
|
form.token = '';
|
|
|
|
|
|
form.isDefault = row.isDefault;
|
|
|
|
|
|
form.allowInsecureTls = row.allowInsecureTls;
|
|
|
|
|
|
form.enabled = row.enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.get<TahesabConnectionDto[]>(
|
|
|
|
|
|
'/integrations/tahesab/connections',
|
|
|
|
|
|
);
|
|
|
|
|
|
items.value = data;
|
|
|
|
|
|
if (!editingId.value) form.isDefault = data.length === 0;
|
|
|
|
|
|
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
2026-09-21 21:25:34 +03:30
|
|
|
|
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری اتصالات ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری اتصالات ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (editingId.value) {
|
|
|
|
|
|
const body: UpdateTahesabConnectionRequest = {
|
|
|
|
|
|
label: form.label.trim(),
|
|
|
|
|
|
baseUrl: form.baseUrl.trim(),
|
|
|
|
|
|
dbName: form.dbName.trim(),
|
|
|
|
|
|
isDefault: form.isDefault,
|
|
|
|
|
|
allowInsecureTls: form.allowInsecureTls,
|
|
|
|
|
|
enabled: form.enabled,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (form.token.trim()) body.token = form.token.trim();
|
|
|
|
|
|
await api.patch(
|
|
|
|
|
|
`/integrations/tahesab/connections/${editingId.value}`,
|
|
|
|
|
|
body,
|
|
|
|
|
|
);
|
|
|
|
|
|
ok.value = 'اتصال بهروزرسانی شد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (!form.token.trim()) {
|
|
|
|
|
|
error.value = 'توکن الزامی است.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const body: CreateTahesabConnectionRequest = {
|
|
|
|
|
|
label: form.label.trim(),
|
|
|
|
|
|
baseUrl: form.baseUrl.trim(),
|
|
|
|
|
|
dbName: form.dbName.trim(),
|
|
|
|
|
|
token: form.token.trim(),
|
|
|
|
|
|
isDefault: form.isDefault,
|
|
|
|
|
|
allowInsecureTls: form.allowInsecureTls,
|
|
|
|
|
|
enabled: form.enabled,
|
|
|
|
|
|
};
|
|
|
|
|
|
await api.post('/integrations/tahesab/connections', body);
|
|
|
|
|
|
ok.value = 'اتصال افزوده شد.';
|
|
|
|
|
|
}
|
|
|
|
|
|
resetForm();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'ذخیره ناموفق بود';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function testConn(id: string) {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<{ ok: boolean; error?: string }>(
|
|
|
|
|
|
`/integrations/tahesab/connections/${id}/test`,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (data.ok) ok.value = 'اتصال سالم است.';
|
2026-09-05 09:52:27 +03:30
|
|
|
|
else {
|
|
|
|
|
|
error.value = data.error ?? 'تست اتصال ناموفق بود.';
|
|
|
|
|
|
if (isTlsError(data.error)) {
|
|
|
|
|
|
error.value +=
|
|
|
|
|
|
' → دکمه «فعالسازی SSL خودامضا» را بزنید یا در ویرایش اتصال تیک آن را بگذارید.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
2026-09-05 09:52:27 +03:30
|
|
|
|
error.value = apiErr(e, 'تست ناموفق');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function enableInsecureTls(id: string) {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/integrations/tahesab/connections/${id}`, {
|
|
|
|
|
|
allowInsecureTls: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
ok.value = 'پذیرش گواهی خودامضا فعال شد. در حال تست…';
|
|
|
|
|
|
await load();
|
|
|
|
|
|
await testConn(id);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiErr(e, 'فعالسازی ناموفق بود');
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeConn(id: string) {
|
|
|
|
|
|
if (!confirm('این اتصال حذف شود؟ لینک مشتریان مرتبط هم پاک میشود.')) return;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/integrations/tahesab/connections/${id}`);
|
|
|
|
|
|
ok.value = 'اتصال حذف شد.';
|
|
|
|
|
|
if (editingId.value === id) resetForm();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String((e.response?.data as { message?: string })?.message ?? 'حذف ناموفق')
|
|
|
|
|
|
: 'حذف ناموفق';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
async function searchTahesab(mode: 'range' | 'filter') {
|
2026-09-04 22:00:23 +03:30
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
2026-09-05 09:33:12 +03:30
|
|
|
|
if (!sync.connectionId) {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
error.value = 'ابتدا یک اتصال انتخاب کنید.';
|
2026-09-05 09:33:12 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
syncBusy.value = true;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
try {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const body: Record<string, unknown> = { connectionId: sync.connectionId };
|
2026-09-05 09:33:12 +03:30
|
|
|
|
if (mode === 'range') {
|
2026-09-05 10:30:54 +03:30
|
|
|
|
const fromRaw = String(sync.fromCode ?? '').trim();
|
|
|
|
|
|
const toRaw = String(sync.toCode ?? '').trim();
|
|
|
|
|
|
if (!fromRaw && !toRaw) {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
body.fromCode = 1;
|
|
|
|
|
|
body.toCode = 5000;
|
2026-09-05 10:30:54 +03:30
|
|
|
|
} else {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const fromCode = fromRaw ? Number(fromRaw) : 1;
|
|
|
|
|
|
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
|
|
|
|
|
|
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
|
|
|
|
|
|
error.value = 'بازه کد مشتری نامعتبر است.';
|
2026-09-05 10:30:54 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
body.fromCode = fromCode;
|
|
|
|
|
|
body.toCode = toCode;
|
2026-09-05 09:33:12 +03:30
|
|
|
|
}
|
|
|
|
|
|
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
|
2026-09-04 22:00:23 +03:30
|
|
|
|
body.moshtariCode = Number(sync.moshtariCode);
|
|
|
|
|
|
} else if (sync.phone.trim()) {
|
|
|
|
|
|
body.phone = sync.phone.trim();
|
|
|
|
|
|
} else if (sync.name.trim()) {
|
|
|
|
|
|
body.name = sync.name.trim();
|
|
|
|
|
|
} else {
|
2026-09-05 09:33:12 +03:30
|
|
|
|
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
|
2026-09-04 22:00:23 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-05 09:33:12 +03:30
|
|
|
|
const { data } = await api.post<{ results: SyncResult[] }>(
|
|
|
|
|
|
'/integrations/tahesab/sync/search',
|
|
|
|
|
|
body,
|
2026-09-05 10:41:12 +03:30
|
|
|
|
{ timeout: 60_000 },
|
2026-09-05 09:33:12 +03:30
|
|
|
|
);
|
2026-09-04 22:00:23 +03:30
|
|
|
|
syncResults.value = data.results ?? [];
|
2026-09-05 09:33:12 +03:30
|
|
|
|
if (!syncResults.value.length) {
|
|
|
|
|
|
ok.value = 'نتیجهای یافت نشد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
|
|
|
|
|
|
ok.value = `${syncResults.value.length} مشتری پیدا شد` + (linked ? ` (${linked} قبلاً لینک شده)` : '');
|
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
|
} catch (e) {
|
2026-09-05 09:42:08 +03:30
|
|
|
|
error.value = apiErr(e, 'جستجو ناموفق');
|
2026-09-05 09:33:12 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
syncBusy.value = false;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function importOne(moshtariCode: number) {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
try {
|
2026-09-05 09:33:12 +03:30
|
|
|
|
syncBusy.value = true;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
const { data } = await api.post<{ personId: string }>(
|
|
|
|
|
|
'/integrations/tahesab/sync/import',
|
|
|
|
|
|
{ connectionId: sync.connectionId, moshtariCode },
|
|
|
|
|
|
);
|
2026-09-05 09:33:12 +03:30
|
|
|
|
ok.value = `وارد شد — در مخاطبین با شناسه ${data.personId}`;
|
|
|
|
|
|
const row = syncResults.value.find((r) => r.moshtariCode === moshtariCode);
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
row.alreadyLinked = true;
|
|
|
|
|
|
row.personId = data.personId;
|
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
|
} catch (e) {
|
2026-09-05 09:42:08 +03:30
|
|
|
|
error.value = apiErr(e, 'وارد کردن ناموفق');
|
2026-09-05 09:33:12 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
syncBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function importBulk(mode: 'results' | 'range') {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
if (!sync.connectionId) {
|
|
|
|
|
|
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const body: Record<string, unknown> = { connectionId: sync.connectionId };
|
|
|
|
|
|
if (mode === 'results') {
|
|
|
|
|
|
const codes = pendingImportCodes();
|
|
|
|
|
|
if (!codes.length) {
|
|
|
|
|
|
error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه لینک شدهاند یا لیست خالی است).';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
!confirm(
|
2026-09-07 11:49:54 +03:30
|
|
|
|
`${codes.length} مشتری جدید از تهحساب بهعنوان شخص در ${brand.nameFa} ساخته و لینک میشوند. ادامه؟`,
|
2026-09-05 09:33:12 +03:30
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
body.moshtariCodes = codes;
|
|
|
|
|
|
} else {
|
2026-09-05 10:30:54 +03:30
|
|
|
|
const fromRaw = String(sync.fromCode ?? '').trim();
|
|
|
|
|
|
const toRaw = String(sync.toCode ?? '').trim();
|
|
|
|
|
|
if (!fromRaw && !toRaw) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!confirm(
|
2026-09-12 01:33:07 +03:30
|
|
|
|
`کل مشتریان از کد ۱ تا ۵۰۰۰ از تهحساب بهعنوان شخص وارد ${brand.nameFa} میشوند. ادامه؟`,
|
2026-09-05 10:30:54 +03:30
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
body.fromCode = 1;
|
|
|
|
|
|
body.toCode = 5000;
|
2026-09-05 10:30:54 +03:30
|
|
|
|
} else {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const fromCode = fromRaw ? Number(fromRaw) : 1;
|
|
|
|
|
|
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
|
|
|
|
|
|
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
|
|
|
|
|
|
error.value = 'بازه نامعتبر است.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (toCode - fromCode > 2000) {
|
|
|
|
|
|
error.value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.';
|
2026-09-05 10:30:54 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
!confirm(
|
2026-09-12 01:33:07 +03:30
|
|
|
|
`مشتریان با کد ${fromCode} تا ${toCode} از تهحساب وارد ${brand.nameFa} میشوند. ادامه؟`,
|
2026-09-05 10:30:54 +03:30
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
body.fromCode = fromCode;
|
|
|
|
|
|
body.toCode = toCode;
|
2026-09-05 09:33:12 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
syncBusy.value = true;
|
|
|
|
|
|
const { data } = await api.post<{
|
|
|
|
|
|
imported: number;
|
|
|
|
|
|
skipped: number;
|
|
|
|
|
|
failed: number;
|
|
|
|
|
|
}>('/integrations/tahesab/sync/import-bulk', body);
|
|
|
|
|
|
ok.value = `وارد شد: ${data.imported} · قبلاً بود: ${data.skipped} · ناموفق: ${data.failed}`;
|
|
|
|
|
|
await searchTahesab(mode === 'range' ? 'range' : 'filter');
|
|
|
|
|
|
} catch (e) {
|
2026-09-05 09:42:08 +03:30
|
|
|
|
error.value = apiErr(e, 'وارد کردن دستهای ناموفق');
|
2026-09-05 09:33:12 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
syncBusy.value = false;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 21:25:34 +03:30
|
|
|
|
async function searchProducts() {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
if (!productSync.connectionId) {
|
|
|
|
|
|
error.value = 'ابتدا اتصال را برای کارساخته انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
productSyncBusy.value = true;
|
|
|
|
|
|
const body: Record<string, unknown> = {
|
|
|
|
|
|
connectionId: productSync.connectionId,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (productSync.q.trim()) body.q = productSync.q.trim();
|
|
|
|
|
|
const { data } = await api.post<{
|
|
|
|
|
|
results: ProductSyncResult[];
|
|
|
|
|
|
categoryLabel?: string;
|
|
|
|
|
|
}>('/integrations/tahesab/sync/products/search', body, { timeout: 60_000 });
|
|
|
|
|
|
productSyncResults.value = data.results ?? [];
|
|
|
|
|
|
if (data.categoryLabel) productCategoryLabel.value = data.categoryLabel;
|
|
|
|
|
|
if (!productSyncResults.value.length) {
|
|
|
|
|
|
ok.value = 'کارساختهای یافت نشد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const linked = productSyncResults.value.filter((r) => r.alreadyImported).length;
|
|
|
|
|
|
ok.value =
|
|
|
|
|
|
`${productSyncResults.value.length} ${productCategoryLabel.value} پیدا شد` +
|
|
|
|
|
|
(linked ? ` (${linked} قبلاً وارد شده)` : '');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiErr(e, 'دریافت لیست کارساخته ناموفق');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
productSyncBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function importProductOne(name: string) {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
productSyncBusy.value = true;
|
|
|
|
|
|
const { data } = await api.post<{ productId: string }>(
|
|
|
|
|
|
'/integrations/tahesab/sync/products/import',
|
|
|
|
|
|
{ connectionId: productSync.connectionId, name },
|
|
|
|
|
|
);
|
|
|
|
|
|
ok.value = `وارد شد — در محصولات با شناسه ${data.productId}`;
|
|
|
|
|
|
const row = productSyncResults.value.find((r) => r.name === name);
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
row.alreadyImported = true;
|
|
|
|
|
|
row.productId = data.productId;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiErr(e, 'وارد کردن محصول ناموفق');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
productSyncBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function importProductsBulk(mode: 'pending' | 'all') {
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
ok.value = '';
|
|
|
|
|
|
if (!productSync.connectionId) {
|
|
|
|
|
|
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const body: Record<string, unknown> = { connectionId: productSync.connectionId };
|
|
|
|
|
|
if (mode === 'pending') {
|
|
|
|
|
|
const names = pendingProductNames();
|
|
|
|
|
|
if (!names.length) {
|
|
|
|
|
|
error.value = `${productCategoryLabel.value} جدیدی برای وارد کردن نیست.`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
!confirm(
|
|
|
|
|
|
`${names.length} ${productCategoryLabel.value} بهعنوان محصول در ${brand.nameFa} ساخته میشود. ادامه؟`,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
body.names = names;
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
!confirm(
|
|
|
|
|
|
`همهٔ نامهای ${productCategoryLabel.value} از تهحساب وارد محصولات ${brand.nameFa} میشوند. ادامه؟`,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
productSyncBusy.value = true;
|
|
|
|
|
|
const { data } = await api.post<{
|
|
|
|
|
|
imported: number;
|
|
|
|
|
|
skipped: number;
|
|
|
|
|
|
failed: number;
|
|
|
|
|
|
}>('/integrations/tahesab/sync/products/import-bulk', body, { timeout: 120_000 });
|
|
|
|
|
|
ok.value = `وارد شد: ${data.imported} · قبلاً بود: ${data.skipped} · ناموفق: ${data.failed}`;
|
|
|
|
|
|
await searchProducts();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiErr(e, 'وارد کردن دستهای محصولات ناموفق');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
productSyncBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 22:00:23 +03:30
|
|
|
|
onMounted(load);
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<section class="crm-page tahesab-page" dir="rtl">
|
|
|
|
|
|
<UiPageHeader
|
|
|
|
|
|
title="اتصال نرمافزار مالی تهحساب"
|
|
|
|
|
|
description="همگامسازی اشخاص، ماندهحسابها و اسناد مالی بین تهحساب و نرمافزار مدیریت ارتباط با مشتریان"
|
|
|
|
|
|
/>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
|
|
|
|
|
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
2026-09-05 09:52:27 +03:30
|
|
|
|
<div
|
|
|
|
|
|
v-if="items.some((r) => !r.allowInsecureTls && isTlsError(r.lastError))"
|
2026-09-12 01:33:07 +03:30
|
|
|
|
class="crm-alert crm-alert-warning"
|
2026-09-05 09:52:27 +03:30
|
|
|
|
>
|
|
|
|
|
|
اتصال شما بهخاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
|
|
|
|
|
|
<strong>فعالسازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard v-if="!loading && items.length" title="اتصالات تعریفشده" class="section-card">
|
|
|
|
|
|
<div class="connections-list">
|
|
|
|
|
|
<div v-for="row in items" :key="row.id" class="connection-row">
|
|
|
|
|
|
<div class="connection-info">
|
|
|
|
|
|
<div class="connection-title-row">
|
|
|
|
|
|
<strong class="connection-label">{{ row.label }}</strong>
|
|
|
|
|
|
<UiBadge v-if="row.isDefault" variant="primary">پیشفرض</UiBadge>
|
|
|
|
|
|
<UiBadge :variant="row.enabled ? 'success' : 'neutral'">
|
|
|
|
|
|
{{ row.enabled ? 'فعال' : 'غیرفعال' }}
|
|
|
|
|
|
</UiBadge>
|
|
|
|
|
|
<UiBadge v-if="row.allowInsecureTls" variant="warning">SSL خودامضا</UiBadge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="connection-url font-mono">{{ row.baseUrl }}</p>
|
|
|
|
|
|
<p class="connection-meta">
|
|
|
|
|
|
<span>بانک: {{ row.dbName }}</span>
|
|
|
|
|
|
<span class="meta-dot">·</span>
|
|
|
|
|
|
<span>توکن: {{ row.tokenMasked }}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="row.lastHealthAt"
|
|
|
|
|
|
class="health-status"
|
|
|
|
|
|
:class="row.lastHealthOk ? 'ok' : 'bad'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="health-indicator"></span>
|
|
|
|
|
|
<span>آخرین وضعیت: {{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}</span>
|
|
|
|
|
|
<span v-if="row.lastError"> — {{ row.lastError }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="connection-actions">
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
v-if="!row.allowInsecureTls && isTlsError(row.lastError)"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
@click="enableInsecureTls(row.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
فعالسازی SSL خودامضا
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton variant="ghost" size="sm" @click="startEdit(row)">
|
|
|
|
|
|
<template #icon>
|
|
|
|
|
|
<UiIcon name="pencil" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
ویرایش
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton variant="ghost" size="sm" @click="testConn(row.id)">
|
|
|
|
|
|
<template #icon>
|
|
|
|
|
|
<UiIcon name="refresh" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
تست اتصال
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton variant="danger" size="sm" @click="removeConn(row.id)">
|
|
|
|
|
|
<template #icon>
|
|
|
|
|
|
<UiIcon name="trash" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
حذف
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</div>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
</div>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<UiCard :title="editingId ? 'ویرایش اتصال تهحساب' : 'افزودن اتصال جدید تهحساب'" class="section-card">
|
|
|
|
|
|
<form class="crm-form" @submit.prevent="save">
|
|
|
|
|
|
<div class="crm-form-grid-2">
|
|
|
|
|
|
<UiField label="عنوان اتصال" required>
|
|
|
|
|
|
<UiInput v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه مرکزی، دیتابیس مالی" />
|
|
|
|
|
|
</UiField>
|
|
|
|
|
|
|
|
|
|
|
|
<UiField label="آدرس وبسرویس (Base URL)" required hint="آدرس سرور وبسرویس تهحساب">
|
|
|
|
|
|
<UiInput
|
|
|
|
|
|
v-model="form.baseUrl"
|
|
|
|
|
|
required
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
placeholder="https://IP:8081 یا https://api.tahesab.ir"
|
|
|
|
|
|
@blur="onBaseUrlBlur"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</UiField>
|
|
|
|
|
|
|
|
|
|
|
|
<UiField label="نام دیتابیس (DBName)" required>
|
|
|
|
|
|
<UiInput v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
|
|
|
|
|
|
</UiField>
|
|
|
|
|
|
|
|
|
|
|
|
<UiField label="توکن دسترسی (Bearer Token)" :required="!editingId" :hint="editingId ? 'خالی بگذارید تا توکن فعلی تغییر نکند.' : 'توکن احراز هویت وبسرویس'">
|
|
|
|
|
|
<UiInput
|
|
|
|
|
|
v-model="form.token"
|
|
|
|
|
|
:required="!editingId"
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
autocomplete="new-password"
|
|
|
|
|
|
placeholder="توکن امنیتی..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</UiField>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="checkbox-group">
|
|
|
|
|
|
<label class="custom-check">
|
|
|
|
|
|
<input v-model="form.isDefault" type="checkbox" />
|
|
|
|
|
|
<span>اتصال پیشفرض سازمان</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="custom-check">
|
|
|
|
|
|
<input v-model="form.enabled" type="checkbox" />
|
|
|
|
|
|
<span>اتصال فعال باشد</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="custom-check highlight">
|
|
|
|
|
|
<input v-model="form.allowInsecureTls" type="checkbox" />
|
|
|
|
|
|
<span>پذیرش گواهی SSL خودامضا (برای IP محلی یا پورت داخلی بدون گواهی رسمی)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="form-actions-row">
|
|
|
|
|
|
<UiButton type="submit" :loading="saving">
|
|
|
|
|
|
{{ editingId ? 'بهروزرسانی اتصال' : 'افزودن اتصال' }}
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
|
|
|
|
|
|
انصراف
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<UiCard title="وارد کردن و همگامسازی مشتریان از تهحساب" class="section-card">
|
|
|
|
|
|
<p class="sync-desc">
|
|
|
|
|
|
با استفاده از این بخش میتوانید اشخاص و طرفحسابهای تهحساب را به عنوان مخاطب وارد نرمافزار کرده و سوابق مالی آنها را پیوند دهید.
|
2026-09-04 22:00:23 +03:30
|
|
|
|
</p>
|
2026-09-05 09:33:12 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="crm-form">
|
|
|
|
|
|
<UiField label="اتصال مورد نظر جهت همگامسازی" required>
|
|
|
|
|
|
<UiSelect v-model="sync.connectionId">
|
|
|
|
|
|
<option disabled value="">انتخاب اتصال</option>
|
|
|
|
|
|
<option v-for="row in items" :key="row.id" :value="row.id">
|
|
|
|
|
|
{{ row.label }} ({{ row.dbName }})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</UiSelect>
|
|
|
|
|
|
</UiField>
|
2026-09-05 09:33:12 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="sync-sub-section">
|
|
|
|
|
|
<h4 class="sync-subhead">۱) وارد کردن دستهای با بازه کد مشتری</h4>
|
|
|
|
|
|
<p class="sync-sub-desc">
|
|
|
|
|
|
در صورت خالی بودن «از» و «تا»، مشتریان کد ۱ تا ۵۰۰۰ بررسی میشوند (حداکثر ۲۰۰۰ کد در هر درخواست).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="crm-form-grid-2">
|
|
|
|
|
|
<UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" />
|
|
|
|
|
|
<UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (مثلاً ۵۰۰)" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sync-buttons">
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
:disabled="!sync.connectionId || syncBusy"
|
|
|
|
|
|
@click="searchTahesab('range')"
|
|
|
|
|
|
>
|
|
|
|
|
|
نمایش بازه
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
:disabled="!sync.connectionId || syncBusy"
|
|
|
|
|
|
@click="importBulk('range')"
|
|
|
|
|
|
>
|
|
|
|
|
|
وارد کردن کل بازه
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-05 09:33:12 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="sync-sub-section">
|
|
|
|
|
|
<h4 class="sync-subhead">۲) جستجوی تکی یا فیلتر مشخصات</h4>
|
|
|
|
|
|
<div class="crm-form-grid-3">
|
|
|
|
|
|
<UiInput v-model="sync.phone" dir="ltr" placeholder="موبایل یا تلفن..." />
|
|
|
|
|
|
<UiInput v-model="sync.name" placeholder="نام یا عنوان مشتری..." />
|
|
|
|
|
|
<UiInput v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری تهحساب..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sync-buttons">
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
:disabled="!sync.connectionId || syncBusy"
|
|
|
|
|
|
@click="searchTahesab('filter')"
|
|
|
|
|
|
>
|
|
|
|
|
|
جستجو در تهحساب
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
|
|
|
|
|
|
@click="importBulk('results')"
|
|
|
|
|
|
>
|
|
|
|
|
|
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="syncResults.length" class="sync-results-box">
|
|
|
|
|
|
<h4 class="sync-results-title">نتایج جستجو ({{ syncResults.length }})</h4>
|
|
|
|
|
|
<div class="crm-table-container">
|
|
|
|
|
|
<table class="crm-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>کد مشتری</th>
|
|
|
|
|
|
<th>نام و نام خانوادگی</th>
|
|
|
|
|
|
<th>تلفن</th>
|
|
|
|
|
|
<th>وضعیت لینک</th>
|
|
|
|
|
|
<th>عملیات</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="r in syncResults" :key="String(r.moshtariCode)">
|
|
|
|
|
|
<td class="font-mono">{{ r.moshtariCode }}</td>
|
|
|
|
|
|
<td class="font-bold">{{ r.name || 'بدون نام' }}</td>
|
|
|
|
|
|
<td class="font-mono" dir="ltr">{{ r.tel }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<UiBadge v-if="r.alreadyLinked" variant="success">
|
|
|
|
|
|
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
|
|
|
|
|
|
</UiBadge>
|
|
|
|
|
|
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
v-if="!r.alreadyLinked"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="!r.moshtariCode || syncBusy"
|
|
|
|
|
|
@click="importOne(r.moshtariCode!)"
|
|
|
|
|
|
>
|
|
|
|
|
|
وارد کردن
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<RouterLink
|
|
|
|
|
|
v-else-if="r.personId"
|
|
|
|
|
|
:to="{ name: 'person-detail', params: { id: r.personId } }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiButton variant="ghost" size="sm">
|
|
|
|
|
|
مشاهده شخص
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</RouterLink>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</UiCard>
|
2026-09-21 21:25:34 +03:30
|
|
|
|
|
|
|
|
|
|
<UiCard title="وارد کردن کارساخته از تهحساب به محصولات" class="section-card">
|
|
|
|
|
|
<p class="sync-desc">
|
|
|
|
|
|
نامهای کارساختهٔ تهحساب (لیست <span class="font-mono" dir="ltr">DoListNameKarsakhte</span>)
|
|
|
|
|
|
را بهعنوان محصول با دستهٔ «{{ productCategoryLabel }}» وارد کنید.
|
|
|
|
|
|
موجودی همچنان از تهحساب است؛ اینجا فقط کاتالوگ برای معامله/فاکتور ساخته میشود.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="crm-form">
|
|
|
|
|
|
<UiField label="اتصال مورد نظر" required>
|
|
|
|
|
|
<UiSelect v-model="productSync.connectionId">
|
|
|
|
|
|
<option disabled value="">انتخاب اتصال</option>
|
|
|
|
|
|
<option v-for="row in items" :key="row.id" :value="row.id">
|
|
|
|
|
|
{{ row.label }} ({{ row.dbName }})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</UiSelect>
|
|
|
|
|
|
</UiField>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="sync-sub-section">
|
|
|
|
|
|
<h4 class="sync-subhead">لیست و جستجو</h4>
|
|
|
|
|
|
<div class="crm-form-grid-2">
|
|
|
|
|
|
<UiInput
|
|
|
|
|
|
v-model="productSync.q"
|
|
|
|
|
|
placeholder="جستجو نام یا عیار…"
|
|
|
|
|
|
@keydown.enter.prevent="searchProducts"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="sync-buttons" style="margin-top: 0">
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
:disabled="!productSync.connectionId || productSyncBusy"
|
|
|
|
|
|
@click="searchProducts"
|
|
|
|
|
|
>
|
|
|
|
|
|
دریافت لیست
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
:disabled="!productSync.connectionId || productSyncBusy || !pendingProductNames().length"
|
|
|
|
|
|
@click="importProductsBulk('pending')"
|
|
|
|
|
|
>
|
|
|
|
|
|
ورود نتایج جدید ({{ pendingProductNames().length }})
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
:disabled="!productSync.connectionId || productSyncBusy"
|
|
|
|
|
|
@click="importProductsBulk('all')"
|
|
|
|
|
|
>
|
|
|
|
|
|
ورود همه
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="productSyncResults.length" class="sync-results-box">
|
|
|
|
|
|
<h4 class="sync-results-title">
|
|
|
|
|
|
نتایج ({{ productSyncResults.length }}) — دسته: {{ productCategoryLabel }}
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<div class="crm-table-container">
|
|
|
|
|
|
<table class="crm-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>نام</th>
|
|
|
|
|
|
<th>عیار</th>
|
|
|
|
|
|
<th>آخرین سند</th>
|
|
|
|
|
|
<th>وضعیت</th>
|
|
|
|
|
|
<th>عملیات</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="r in productSyncResults" :key="r.name">
|
|
|
|
|
|
<td class="font-bold">{{ r.name }}</td>
|
|
|
|
|
|
<td class="font-mono" dir="ltr">{{ r.ayar ?? '—' }}</td>
|
|
|
|
|
|
<td>{{ r.lastSanad || '—' }}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<UiBadge v-if="r.alreadyImported" variant="success">
|
|
|
|
|
|
وارد شده{{ r.productName ? ` (${r.productName})` : '' }}
|
|
|
|
|
|
</UiBadge>
|
|
|
|
|
|
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
v-if="!r.alreadyImported"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="productSyncBusy"
|
|
|
|
|
|
@click="importProductOne(r.name)"
|
|
|
|
|
|
>
|
|
|
|
|
|
وارد کردن
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<RouterLink
|
|
|
|
|
|
v-else-if="r.productId"
|
|
|
|
|
|
:to="{ name: 'product-edit', params: { id: r.productId } }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiButton variant="ghost" size="sm">مشاهده محصول</UiButton>
|
|
|
|
|
|
</RouterLink>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</UiCard>
|
2026-09-04 22:00:23 +03:30
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.tahesab-page {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-6);
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.section-card {
|
|
|
|
|
|
padding: var(--sp-6);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.connections-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.connection-row {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: flex-start;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-4);
|
|
|
|
|
|
padding: var(--sp-4) var(--sp-5);
|
|
|
|
|
|
background: var(--color-surface);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
transition: border-color var(--transition-fast);
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.connection-row:hover {
|
|
|
|
|
|
border-color: var(--color-brand-300, #5eead4);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.connection-info {
|
2026-09-04 22:00:23 +03:30
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-1);
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.connection-title-row {
|
2026-09-04 22:00:23 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-2);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
flex-wrap: wrap;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.connection-label {
|
|
|
|
|
|
font-size: var(--fs-md);
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.connection-url {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
direction: ltr;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.connection-meta {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.meta-dot {
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-status {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
margin-top: var(--sp-1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-indicator {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-status.ok {
|
|
|
|
|
|
color: var(--color-success-text, #15803d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-status.ok .health-indicator {
|
|
|
|
|
|
background: var(--color-success-text, #15803d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-status.bad {
|
|
|
|
|
|
color: var(--color-danger-text, #b91c1c);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-status.bad .health-indicator {
|
|
|
|
|
|
background: var(--color-danger-text, #b91c1c);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.connection-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.checkbox-group {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
margin: var(--sp-2) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.custom-check {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2026-09-04 22:00:23 +03:30
|
|
|
|
cursor: pointer;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
user-select: none;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.custom-check.highlight {
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-warning-bg, #fffbeb);
|
|
|
|
|
|
border: 1px solid var(--color-warning-border, #fef3c7);
|
|
|
|
|
|
color: var(--color-warning-text, #92400e);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
font-weight: 600;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.form-actions-row {
|
2026-09-05 09:33:12 +03:30
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
margin-top: var(--sp-2);
|
2026-09-05 09:33:12 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-desc {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
margin-top: 0;
|
2026-09-05 09:33:12 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-sub-section {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
padding: var(--sp-4);
|
|
|
|
|
|
background: var(--color-surface-soft);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
2026-09-05 09:33:12 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-subhead {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
2026-09-05 09:33:12 +03:30
|
|
|
|
font-weight: 700;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
color: var(--color-text-primary);
|
2026-09-04 22:00:23 +03:30
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-sub-desc {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sync-buttons {
|
2026-09-04 22:00:23 +03:30
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-results-box {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
margin-top: var(--sp-2);
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.sync-results-title {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.font-mono {
|
|
|
|
|
|
font-family: var(--font-mono, monospace);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.font-bold {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
|
.connection-row {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.connection-actions {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
2026-09-04 22:00:23 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|