Files
CRM_Montzar/apps/web/src/views/TahesabSettingsView.vue
T

1203 lines
39 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateTahesabConnectionRequest,
TahesabConnectionDto,
UpdateTahesabConnectionRequest,
} from '@mento/shared';
import { brand } from '@mento/shared';
import {
UiBadge,
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiPagination,
UiSelect,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
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,
fromCode: '',
toCode: '',
});
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[]>([]);
/** حالت آخرین جستجو — برای پیجینیت بازهٔ کد */
const syncListMode = ref<'none' | 'listAll' | 'range' | 'filter'>('none');
const syncListPage = ref(1);
const syncListMeta = reactive({
fromCode: 0,
toCode: 0,
hasMore: false,
pageSize: 2000,
});
const SYNC_TABLE_PAGE_SIZE = 50;
const syncTablePage = ref(1);
const syncCodeTotalPages = computed(() => {
if (syncListMode.value !== 'listAll') return 1;
// اگر صفحه فعلی نتیجه دارد، صفحهٔ بعد هم قابل امتحان است
return syncListMeta.hasMore
? syncListPage.value + 1
: Math.max(1, syncListPage.value);
});
const syncTableTotalPages = computed(() =>
Math.max(1, Math.ceil(syncResults.value.length / SYNC_TABLE_PAGE_SIZE)),
);
const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * SYNC_TABLE_PAGE_SIZE;
return syncResults.value.slice(start, start + SYNC_TABLE_PAGE_SIZE);
});
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('کارساخته');
function pendingImportCodes() {
return syncResults.value
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
.map((r) => r.moshtariCode as number);
}
function pendingProductNames() {
return productSyncResults.value
.filter((r) => !r.alreadyImported)
.map((r) => r.name);
}
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;
}
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;
}
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;
}
}
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;
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
} 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 = 'اتصال سالم است.';
else {
error.value = data.error ?? 'تست اتصال ناموفق بود.';
if (isTlsError(data.error)) {
error.value +=
' → دکمه «فعال‌سازی SSL خودامضا» را بزنید یا در ویرایش اتصال تیک آن را بگذارید.';
}
}
await load();
} catch (e) {
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, 'فعال‌سازی ناموفق بود');
}
}
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 ?? 'حذف ناموفق')
: 'حذف ناموفق';
}
}
async function searchTahesab(mode: 'range' | 'filter', codePage?: number) {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا یک اتصال انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const body: Record<string, unknown> = { connectionId: sync.connectionId };
if (mode === 'range') {
const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) {
// همهٔ مشتریان — صفحه‌بندی بازهٔ کد (هر صفحه ۲۰۰۰ کد؛ بدون سقف ۵۰۰۰)
const page = Math.max(1, codePage ?? syncListPage.value);
body.listAll = true;
body.page = page;
body.pageSize = 2000;
syncListPage.value = page;
syncListMode.value = 'listAll';
} else {
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 = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است. برای همه، از و تا را خالی بگذارید.';
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
syncListMode.value = 'range';
syncListMeta.hasMore = false;
}
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
body.moshtariCode = Number(sync.moshtariCode);
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else if (sync.phone.trim()) {
body.phone = sync.phone.trim();
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else if (sync.name.trim()) {
body.name = sync.name.trim();
syncListMode.value = 'filter';
syncListMeta.hasMore = false;
} else {
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return;
}
const { data } = await api.post<{
results: SyncResult[];
page?: number;
pageSize?: number;
fromCode?: number;
toCode?: number;
hasMore?: boolean;
note?: string;
}>(
'/integrations/tahesab/sync/search',
body,
{ timeout: body.listAll ? 90_000 : 60_000 },
);
syncResults.value = data.results ?? [];
syncTablePage.value = 1;
if (data.page != null) {
syncListPage.value = data.page;
syncListMeta.fromCode = data.fromCode ?? 0;
syncListMeta.toCode = data.toCode ?? 0;
syncListMeta.hasMore = Boolean(data.hasMore);
syncListMeta.pageSize = data.pageSize ?? 2000;
}
if (!syncResults.value.length) {
ok.value =
data.note ??
(syncListMode.value === 'listAll' && syncListPage.value > 1
? 'در این صفحه مشتری‌ای نیست. صفحه قبل را ببینید یا بازه دستی بزنید.'
: 'نتیجه‌ای یافت نشد.');
} else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
const base =
`${syncResults.value.length} مشتری پیدا شد` +
(linked ? ` (${linked} قبلاً لینک شده)` : '');
ok.value = data.note ? `${base} — ${data.note}` : base;
}
} catch (e) {
error.value = apiErr(e, 'جستجو ناموفق');
} finally {
syncBusy.value = false;
}
}
function onSyncCodePageChange(page: number) {
void searchTahesab('range', page);
}
function onSyncTablePageChange(page: number) {
syncTablePage.value = page;
}
async function importOne(moshtariCode: number) {
error.value = '';
ok.value = '';
try {
syncBusy.value = true;
const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode },
);
ok.value = `وارد شد — در مخاطبین با شناسه ${data.personId}`;
const row = syncResults.value.find((r) => r.moshtariCode === moshtariCode);
if (row) {
row.alreadyLinked = true;
row.personId = data.personId;
}
} catch (e) {
error.value = apiErr(e, 'وارد کردن ناموفق');
} 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(
`${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`,
)
) {
return;
}
body.moshtariCodes = codes;
} else {
const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) {
const page = syncListMode.value === 'listAll' ? syncListPage.value : 1;
const from =
syncListMode.value === 'listAll' && syncListMeta.fromCode
? syncListMeta.fromCode
: (page - 1) * 2000 + 1;
const to =
syncListMode.value === 'listAll' && syncListMeta.toCode
? syncListMeta.toCode
: from + 1999;
if (
!confirm(
`مشتریان صفحهٔ ${page} (کد ${from} تا ${to}) از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. برای صفحات بعدی، پیجینیت را بزنید و دوباره وارد کنید. ادامه؟`,
)
) {
return;
}
body.listAll = true;
body.page = page;
body.pageSize = 2000;
} else {
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 = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.';
return;
}
if (
!confirm(
`مشتریان با کد ${fromCode} تا ${toCode} از ته‌حساب وارد ${brand.nameFa} می‌شوند. ادامه؟`,
)
) {
return;
}
body.fromCode = fromCode;
body.toCode = toCode;
}
}
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) {
error.value = apiErr(e, 'وارد کردن دسته‌ای ناموفق');
} finally {
syncBusy.value = false;
}
}
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;
}
}
onMounted(load);
</script>
<template>
<section class="crm-page tahesab-page" dir="rtl">
<UiPageHeader
title="اتصال نرم‌افزار مالی ته‌حساب"
description="همگام‌سازی اشخاص، مانده‌حساب‌ها و اسناد مالی بین ته‌حساب و نرم‌افزار مدیریت ارتباط با مشتریان"
/>
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
<div
v-if="items.some((r) => !r.allowInsecureTls && isTlsError(r.lastError))"
class="crm-alert crm-alert-warning"
>
اتصال شما به‌خاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
<strong>فعال‌سازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید.
</div>
<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>
</div>
</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>
</div>
<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>
<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">
با استفاده از این بخش می‌توانید اشخاص و طرف‌حساب‌های ته‌حساب را به عنوان مخاطب وارد نرم‌افزار کرده و سوابق مالی آن‌ها را پیوند دهید.
</p>
<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>
<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', 1)"
>
نمایش بازه / همه
</UiButton>
<UiButton
:disabled="!sync.connectionId || syncBusy"
@click="importBulk('range')"
>
وارد کردن این صفحه / بازه
</UiButton>
</div>
<UiPagination
v-if="syncListMode === 'listAll' && (syncResults.length || syncListPage > 1)"
:page="syncListPage"
:total-pages="syncCodeTotalPages"
class="sync-code-pagination"
@update:page="onSyncCodePageChange"
/>
<p
v-if="syncListMode === 'listAll' && syncListMeta.fromCode"
class="sync-sub-desc"
>
صفحه {{ syncListPage }} — کد {{ syncListMeta.fromCode }} تا {{ syncListMeta.toCode }}
<span v-if="syncListMeta.hasMore"> · صفحه بعد برای کدهای بالاتر</span>
</p>
</div>
<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="tc-row">
<TableColumnsMenu table-key="tahesab.customers" align="end" />
</div>
<div class="crm-table-container">
<table
v-table-cols="'tahesab.customers'"
class="crm-table"
:style="{ '--row-start': (syncTablePage - 1) * SYNC_TABLE_PAGE_SIZE }"
>
<thead>
<tr>
<th>کد مشتری</th>
<th>نام و نام خانوادگی</th>
<th>تلفن</th>
<th>وضعیت لینک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="r in syncPagedResults" :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>
<UiPagination
v-if="syncTableTotalPages > 1"
:page="syncTablePage"
:total-pages="syncTableTotalPages"
:total-items="syncResults.length"
:page-size="SYNC_TABLE_PAGE_SIZE"
class="sync-table-pagination"
@update:page="onSyncTablePageChange"
/>
</div>
</div>
</UiCard>
<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="tc-row">
<TableColumnsMenu table-key="tahesab.products" align="end" />
</div>
<div class="crm-table-container">
<table v-table-cols="'tahesab.products'" 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>
</section>
</template>
<style scoped>
.tahesab-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
width: 100%;
max-width: 100%;
}
.section-card {
padding: var(--sp-6);
}
.connections-list {
display: flex;
flex-direction: column;
gap: var(--sp-4);
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
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);
}
.connection-row:hover {
border-color: var(--color-brand-300, #5eead4);
}
.connection-info {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.connection-title-row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.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 {
display: inline-flex;
align-items: center;
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);
cursor: pointer;
user-select: none;
}
.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);
font-weight: 600;
}
.form-actions-row {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.sync-desc {
font-size: var(--fs-sm);
color: var(--color-text-secondary);
margin-top: 0;
}
.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);
}
.sync-subhead {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.sync-sub-desc {
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin: 0;
}
.sync-buttons {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.sync-code-pagination,
.sync-table-pagination {
margin-top: var(--sp-3);
}
.sync-results-box {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-top: var(--sp-2);
}
.sync-results-title {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.font-mono {
font-family: var(--font-mono, monospace);
}
.font-bold {
font-weight: 600;
}
@media (max-width: 640px) {
.connection-row {
flex-direction: column;
align-items: stretch;
}
.connection-actions {
justify-content: flex-end;
}
}
</style>