Files
CRM_Montzar/apps/web/src/views/TahesabSettingsView.vue
T
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30

1579 lines
54 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, watch } 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 { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import {
UiBadge,
UiBusyOverlay,
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);
/** تعداد مشتری در هر درخواست ورود دسته‌ای (برای جلوگیری از تایم‌اوت و نمایش درصد) */
const IMPORT_CHUNK = 25;
const importOverlay = reactive<{ open: boolean; message: string; progress: number | null }>({
open: false,
message: '',
progress: null,
});
type SyncResult = {
moshtariCode: number | null;
name: string;
tel: string;
alreadyLinked?: boolean;
personId?: string | null;
personName?: string | null;
duplicateOfCode?: number | null;
duplicatePersonId?: string | null;
duplicatePersonName?: string | null;
};
const syncResults = ref<SyncResult[]>([]);
/** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */
const LIST_ALL_CODE_PAGE = 2000;
const LIST_ALL_EMPTY_STOP = 2;
const SYNC_TABLE_PAGE_SIZES = [10, 20, 50, 100];
const syncTablePageSize = ref(50);
const syncTablePage = ref(1);
const syncOnlyNew = ref(false);
const autoCount = ref('0');
const autoHours = ref<string[]>([]);
const autoSyncSaving = ref(false);
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
1: [8],
2: [8, 14],
3: [8, 12, 16],
4: [8, 12, 16, 20],
5: [8, 11, 14, 17, 20],
6: [8, 10, 12, 14, 16, 20],
};
const selectedConnection = computed(() =>
items.value.find((row) => row.id === sync.connectionId),
);
function hourLabel(hour: number) {
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
}
function fillAutoSync() {
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
.sort((a, b) => a - b)
.slice(0, 6);
autoCount.value = String(hours.length);
autoHours.value = hours.map(String);
}
function onAutoCount(value: string) {
const count = Math.min(6, Math.max(0, Number(value) || 0));
const preset = AUTO_HOUR_PRESETS[count] ?? [];
const next = autoHours.value.slice(0, count);
while (next.length < count) {
next.push(String(preset[next.length] ?? 8));
}
autoCount.value = String(count);
autoHours.value = next;
}
function setAutoHour(index: number, value: string) {
autoHours.value[index] = value;
}
async function saveAutoSync() {
if (!sync.connectionId) return;
const hours = autoHours.value.map((hour) => Number(hour));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
return;
}
if (new Set(hours).size !== hours.length) {
error.value = 'ساعت‌ها نباید تکراری باشند.';
return;
}
autoSyncSaving.value = true;
error.value = '';
ok.value = '';
try {
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
customerSyncHours: hours,
});
ok.value = hours.length
? 'زمان‌بندی بروزرسانی ذخیره شد.'
: 'بروزرسانی خودکار خاموش شد.';
await load();
} catch (e) {
error.value = apiErr(e, 'ذخیره زمان‌بندی ناموفق بود');
} finally {
autoSyncSaving.value = false;
}
}
/** همان قاعدهٔ سرور: موبایل ایران 09… است، بقیهٔ شماره‌ها فقط رقم‌اند. */
function customerPhoneKey(value?: string | null): string {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
let digits = String(value ?? '')
.replace(/[۰-۹٠-٩]/g, (ch) => {
const faIndex = fa.indexOf(ch);
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
})
.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0098') && digits.length >= 14) digits = digits.slice(4);
else if (digits.startsWith('98') && digits.length >= 12) digits = digits.slice(2);
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
return digits;
}
const linkedPhoneOwners = computed(() => {
const owners = new Map<string, { code: number; personId: string | null; personName: string | null }>();
for (const row of syncResults.value) {
if (!row.alreadyLinked || row.moshtariCode == null) continue;
const key = customerPhoneKey(row.tel);
if (key && !owners.has(key)) {
owners.set(key, {
code: row.moshtariCode,
personId: row.personId ?? null,
personName: row.personName ?? null,
});
}
}
return owners;
});
function duplicateOwner(row: SyncResult) {
if (row.alreadyLinked || row.moshtariCode == null) return null;
if (row.duplicateOfCode != null) {
return {
code: row.duplicateOfCode,
personId: row.duplicatePersonId ?? null,
personName: row.duplicatePersonName ?? null,
};
}
const key = customerPhoneKey(row.tel);
if (!key) return null;
const owner = linkedPhoneOwners.value.get(key);
if (!owner || owner.code === row.moshtariCode) return null;
return owner;
}
const duplicateCustomerCount = computed(
() => syncResults.value.filter((row) => duplicateOwner(row)).length,
);
const syncVisibleResults = computed(() =>
syncOnlyNew.value
? syncResults.value.filter((row) => !row.alreadyLinked && !duplicateOwner(row))
: syncResults.value,
);
const syncTableTotalPages = computed(() =>
Math.max(1, Math.ceil(syncVisibleResults.value.length / syncTablePageSize.value)),
);
const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * syncTablePageSize.value;
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
});
watch(() => sync.connectionId, () => fillAutoSync());
watch([syncOnlyNew, syncTablePageSize], () => {
syncTablePage.value = 1;
});
watch(syncTableTotalPages, (total) => {
if (syncTablePage.value > total) syncTablePage.value = total;
});
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 && !duplicateOwner(r))
.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;
fillAutoSync();
} 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 ?? 'حذف ناموفق')
: 'حذف ناموفق';
}
}
type SyncSearchResponse = { results: SyncResult[] };
async function fetchAllCustomers(): Promise<SyncResult[]> {
const all: SyncResult[] = [];
let page = 1;
let emptyStreak = 0;
importOverlay.open = true;
importOverlay.progress = null;
try {
while (emptyStreak < LIST_ALL_EMPTY_STOP) {
importOverlay.message = `در حال خواندن مشتریان از ته‌حساب… ${toFaDigits(all.length)} مشتری`;
const { data } = await api.post<SyncSearchResponse>(
'/integrations/tahesab/sync/search',
{
connectionId: sync.connectionId,
listAll: true,
page,
pageSize: LIST_ALL_CODE_PAGE,
},
{ timeout: 90_000 },
);
const rows = data.results ?? [];
if (rows.length) {
all.push(...rows);
emptyStreak = 0;
} else {
emptyStreak += 1;
}
page += 1;
}
} finally {
importOverlay.open = false;
}
return all;
}
async function searchTahesab(mode: 'range' | 'filter') {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا یک اتصال انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const body: Record<string, unknown> = { connectionId: sync.connectionId };
let listAll = false;
if (mode === 'range') {
const fromRaw = String(sync.fromCode ?? '').trim();
const toRaw = String(sync.toCode ?? '').trim();
if (!fromRaw && !toRaw) {
listAll = true;
} 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;
}
} else if (sync.moshtariCode !== '' && sync.moshtariCode != null) {
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 {
error.value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.';
return;
}
if (listAll) {
syncResults.value = await fetchAllCustomers();
} else {
const { data } = await api.post<SyncSearchResponse>(
'/integrations/tahesab/sync/search',
body,
{ timeout: 60_000 },
);
syncResults.value = data.results ?? [];
}
syncTablePage.value = 1;
if (!syncResults.value.length) {
ok.value = 'نتیجه‌ای یافت نشد.';
} else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
const duplicate = syncResults.value.filter((r) => duplicateOwner(r)).length;
const pending = syncResults.value.length - linked - duplicate;
ok.value =
`${toFaDigits(syncResults.value.length)} مشتری پیدا شد` +
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده` +
(duplicate ? ` · ${toFaDigits(duplicate)} شماره تکراری` : '');
}
} catch (e) {
error.value = apiErr(e, 'جستجو ناموفق');
} finally {
syncBusy.value = false;
}
}
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() {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
const codes = pendingImportCodes();
if (!codes.length) {
error.value = 'مشتری جدیدی در نتایج برای وارد کردن نیست (همه وارد شده‌اند یا لیست خالی است).';
return;
}
if (
!confirm(
`${codes.length} مشتری جدید از ته‌حساب به‌عنوان شخص در ${brand.nameFa} ساخته و لینک می‌شوند. ادامه؟`,
)
) {
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = 0;
importOverlay.message = 'در حال آماده‌سازی فهرست مشتریان…';
const totals = { imported: 0, skipped: 0, failed: 0 };
let firstError = '';
try {
const total = codes.length;
for (let i = 0; i < total; i += IMPORT_CHUNK) {
const chunk = codes.slice(i, i + IMPORT_CHUNK);
importOverlay.message = `در حال وارد کردن مشتریان… ${toFaDigits(Math.min(i + chunk.length, total))} از ${toFaDigits(total)}`;
try {
const { data } = await api.post<{
imported: number;
skipped: number;
failed: number;
items?: Array<{ moshtariCode: number; ok: boolean; personId?: string; error?: string }>;
}>(
'/integrations/tahesab/sync/import-bulk',
{ connectionId: sync.connectionId, moshtariCodes: chunk },
{ timeout: 120_000 },
);
totals.imported += data.imported;
totals.skipped += data.skipped;
totals.failed += data.failed;
for (const item of data.items ?? []) {
if (!item.ok) {
if (!firstError && item.error) firstError = item.error;
continue;
}
const row = syncResults.value.find((r) => r.moshtariCode === item.moshtariCode);
if (row) {
row.alreadyLinked = true;
if (item.personId) row.personId = item.personId;
}
}
} catch (e) {
totals.failed += chunk.length;
if (!firstError) firstError = apiErr(e, 'خطا در ارسال');
}
importOverlay.progress = total ? ((i + chunk.length) / total) * 100 : 100;
}
ok.value = `وارد شد: ${toFaDigits(totals.imported)} · قبلاً بود: ${toFaDigits(totals.skipped)} · ناموفق: ${toFaDigits(totals.failed)}`;
if (firstError) error.value = `برخی موارد وارد نشدند: ${firstError}`;
} catch (e) {
error.value = apiErr(e, 'وارد کردن دسته‌ای ناموفق');
} finally {
importOverlay.open = false;
importOverlay.progress = null;
syncBusy.value = false;
}
}
/** همهٔ بازه‌های کد خوانده می‌شود و گروه ته‌حساب روی مشتری‌های لینک‌شده نشانده می‌شود */
async function syncGroups() {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = null;
const totals = { rows: 0, linked: 0, updated: 0 };
try {
let page = 1;
let emptyStreak = 0;
while (emptyStreak < LIST_ALL_EMPTY_STOP) {
importOverlay.message = `در حال همگام‌سازی گروه‌ها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`;
const { data } = await api.post<{ rows: number; linked: number; updated: number }>(
'/integrations/tahesab/sync/groups',
{ connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE },
{ timeout: 120_000 },
);
totals.rows += data.rows;
totals.linked += data.linked;
totals.updated += data.updated;
emptyStreak = data.rows ? 0 : emptyStreak + 1;
page += 1;
}
ok.value = `گروه‌ها همگام شد: ${toFaDigits(totals.linked)} مشتری لینک‌شده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر به‌روز شد.`;
} catch (e) {
error.value = apiErr(e, 'همگام‌سازی گروه‌ها ناموفق بود');
} finally {
importOverlay.open = false;
syncBusy.value = false;
}
}
type ExportItem = {
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
};
const EXPORT_CHUNK = 10;
const exportIssues = ref<ExportItem[]>([]);
/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */
async function exportUnlinked() {
error.value = '';
ok.value = '';
exportIssues.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const { data: unlinked } = await api.get<{ ids: string[]; total: number }>(
'/integrations/tahesab/sync/unlinked-people',
{ params: { connectionId: sync.connectionId } },
);
if (!unlinked.total) {
ok.value = 'همهٔ اشخاص به ته‌حساب لینک هستند.';
return;
}
if (
!confirm(
`${toFaDigits(unlinked.total)} شخص به ته‌حساب لینک نیستند.\n` +
'برای هر نفر اول با موبایل/نام در ته‌حساب جستجو می‌شود: اگر دقیقاً یک نفر پیدا شد فقط لینک می‌شود، ' +
'اگر چند نفر پیدا شد رد می‌شود تا دستی بررسی کنید، و اگر پیدا نشد در ته‌حساب ساخته می‌شود. ادامه؟',
)
) {
return;
}
importOverlay.open = true;
importOverlay.progress = 0;
const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 };
const ids = unlinked.ids;
for (let i = 0; i < ids.length; i += EXPORT_CHUNK) {
const chunk = ids.slice(i, i + EXPORT_CHUNK);
importOverlay.message = `در حال ثبت در ته‌حساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`;
try {
const { data } = await api.post<typeof totals & { items: ExportItem[] }>(
'/integrations/tahesab/sync/export-people',
{ connectionId: sync.connectionId, personIds: chunk },
{ timeout: 180_000 },
);
totals.matched += data.matched;
totals.created += data.created;
totals.ambiguous += data.ambiguous;
totals.skipped += data.skipped;
totals.failed += data.failed;
exportIssues.value.push(
...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'),
);
} catch (e) {
totals.failed += chunk.length;
exportIssues.value.push(
...chunk.map((personId) => ({
personId,
name: '',
status: 'error' as const,
error: apiErr(e, 'خطا در ارسال'),
})),
);
}
importOverlay.progress = ((i + chunk.length) / ids.length) * 100;
}
ok.value =
`ثبت در ته‌حساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` +
`ساخته‌شده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` +
`ناموفق ${toFaDigits(totals.failed)}`;
} catch (e) {
error.value = apiErr(e, 'ثبت گروهی در ته‌حساب ناموفق بود');
} finally {
importOverlay.open = false;
importOverlay.progress = null;
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>
<UiField label="تعداد در روز">
<UiSelect
:model-value="autoCount"
:disabled="!sync.connectionId || autoSyncSaving"
@update:model-value="onAutoCount"
>
<option value="0">خاموش</option>
<option v-for="n in 6" :key="n" :value="String(n)">
{{ toFaDigits(n) }} بار
</option>
</UiSelect>
</UiField>
<div v-if="Number(autoCount) > 0" class="crm-form-grid-3">
<UiField
v-for="(_, index) in autoHours"
:key="index"
:label="`ساعت ${toFaDigits(index + 1)}`"
>
<UiSelect
:model-value="autoHours[index]"
:disabled="autoSyncSaving"
@update:model-value="setAutoHour(index, $event)"
>
<option v-for="hour in 24" :key="hour - 1" :value="String(hour - 1)">
{{ hourLabel(hour - 1) }}
</option>
</UiSelect>
</UiField>
</div>
<div class="sync-buttons">
<UiButton
:loading="autoSyncSaving"
:disabled="!sync.connectionId"
@click="saveAutoSync"
>
ذخیره زمان‌بندی
</UiButton>
</div>
<p v-if="selectedConnection?.customerSyncLastRunAt" class="sync-sub-desc">
آخرین اجرا:
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
<template v-if="selectedConnection.customerSyncLastSummary">
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
</template>
</p>
<p v-if="selectedConnection?.customerSyncLastError" class="sync-sub-desc">
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
</p>
</div>
<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 || !pendingImportCodes().length"
@click="importBulk"
>
وارد کردن همهٔ واردنشده‌ها ({{ toFaDigits(pendingImportCodes().length) }})
</UiButton>
</div>
<label class="custom-check">
<input v-model="syncOnlyNew" type="checkbox" />
<span>فقط مشتریانی که وارد نشده‌اند</span>
</label>
</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"
>
وارد کردن نتایج جدید ({{ toFaDigits(pendingImportCodes().length) }})
</UiButton>
</div>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب</h4>
<p class="sync-sub-desc">
«همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض
می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند.
«ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان
می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود.
</p>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="syncGroups"
>
همگام‌سازی گروه‌ها از ته‌حساب
</UiButton>
<UiButton :disabled="!sync.connectionId || syncBusy" @click="exportUnlinked">
ثبت مشتری‌های لینک‌نشده در ته‌حساب
</UiButton>
</div>
<div v-if="exportIssues.length" class="export-issues">
<p class="sync-sub-desc">
این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی»
دستی لینک یا ثبت کنید:
</p>
<ul>
<li v-for="it in exportIssues" :key="it.personId">
<RouterLink :to="{ name: 'person-detail', params: { id: it.personId } }">
{{ it.name || 'بدون نام' }}
</RouterLink>
—
{{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }}
</li>
</ul>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
</h4>
<p v-if="duplicateCustomerCount" class="sync-sub-desc">
{{ toFaDigits(duplicateCustomerCount) }} مشتری شمارهٔ تکراری دارند و جداگانه وارد نمی‌شوند؛ همان شخص با کد دیگری از قبل لینک است.
</p>
<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) * syncTablePageSize }"
>
<thead>
<tr>
<th>کد مشتری</th>
<th>نام و نام خانوادگی</th>
<th>تلفن</th>
<th>وضعیت لینک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-if="!syncPagedResults.length">
<td colspan="5" class="sync-empty-row">
مشتری تازه‌ای برای ورود نیست. شماره‌های تکراری از قبل با کد دیگری لینک شده‌اند.
</td>
</tr>
<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-if="duplicateOwner(r)" variant="warning">
شماره تکراری، کد {{ toFaDigits(duplicateOwner(r)!.code) }}
</UiBadge>
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
</td>
<td>
<UiButton
v-if="!r.alreadyLinked && !duplicateOwner(r)"
size="sm"
:disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</UiButton>
<RouterLink
v-else-if="r.personId || duplicateOwner(r)?.personId"
:to="{ name: 'person-detail', params: { id: (r.personId || duplicateOwner(r)?.personId)! } }"
>
<UiButton variant="ghost" size="sm">
مشاهده شخص
</UiButton>
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
v-if="syncVisibleResults.length > SYNC_TABLE_PAGE_SIZES[0]"
:page="syncTablePage"
:total-pages="syncTableTotalPages"
:total-items="syncVisibleResults.length"
:page-size="syncTablePageSize"
:page-size-options="SYNC_TABLE_PAGE_SIZES"
class="sync-table-pagination"
@update:page="onSyncTablePageChange"
@update:page-size="syncTablePageSize = $event"
/>
</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>
<UiBusyOverlay
:open="importOverlay.open"
:message="importOverlay.message"
:progress="importOverlay.progress"
/>
</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;
}
.sync-empty-row {
text-align: center;
color: var(--muted);
padding: var(--sp-4) var(--sp-3);
}
.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;
}
.export-issues ul {
margin: var(--sp-2) 0 0;
padding-inline-start: 1.2rem;
font-size: var(--fs-xs);
max-height: 14rem;
overflow-y: auto;
}
.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>