2026-09-04 22:00:23 +03:30
< script setup lang = "ts" >
2026-10-04 11:09:40 +03:30
import { computed , onMounted , reactive , ref } from 'vue' ;
2026-10-05 11:56:49 +03:30
import TableColumnsMenu from '@/components/TableColumnsMenu.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-10-05 21:08:38 +03:30
import { toFaDigits } from '@/lib/jalali' ;
2026-09-12 01:33:07 +03:30
import {
UiBadge ,
2026-10-05 21:08:38 +03:30
UiBusyOverlay ,
2026-09-12 01:33:07 +03:30
UiButton ,
UiCard ,
UiField ,
UiInput ,
UiPageHeader ,
2026-10-04 11:09:40 +03:30
UiPagination ,
2026-09-12 01:33:07 +03:30
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 );
2026-10-05 21:08:38 +03:30
/** تعداد مشتری در هر درخواست ورود دستهای (برای جلوگیری از تایماوت و نمایش درصد) */
const IMPORT_CHUNK = 25 ;
const importOverlay = reactive < { open : boolean ; message : string ; progress : number | null } > ({
open : false ,
message : '' ,
progress : null ,
});
2026-09-05 09:33:12 +03:30
type SyncResult = {
moshtariCode : number | null ;
name : string ;
tel : string ;
alreadyLinked ?: boolean ;
personId ?: string | null ;
personName ?: string | null ;
};
const syncResults = ref < SyncResult [] >([]);
2026-10-04 11:09:40 +03:30
/** حالت آخرین جستجو — برای پیجینیت بازهٔ کد */
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 );
});
2026-09-05 09:33:12 +03:30
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-10-04 11:09:40 +03:30
async function searchTahesab ( mode : 'range' | 'filter' , codePage ?: number ) {
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-10-04 11:09:40 +03:30
// همهٔ مشتریان — صفحهبندی بازهٔ کد (هر صفحه ۲۰۰۰ کد؛ بدون سقف ۵۰۰۰)
const page = Math . max ( 1 , codePage ?? syncListPage . value );
2026-10-03 15:28:08 +03:30
body . listAll = true ;
2026-10-04 11:09:40 +03:30
body . page = page ;
body . pageSize = 2000 ;
syncListPage . value = page ;
syncListMode . value = 'listAll' ;
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 ;
}
2026-10-03 15:28:08 +03:30
if ( toCode - fromCode > 2000 ) {
error . value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است. برای همه، از و تا را خالی بگذارید.' ;
return ;
}
2026-09-05 10:30:54 +03:30
body . fromCode = fromCode ;
body . toCode = toCode ;
2026-10-04 11:09:40 +03:30
syncListMode . value = 'range' ;
syncListMeta . hasMore = false ;
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 );
2026-10-04 11:09:40 +03:30
syncListMode . value = 'filter' ;
syncListMeta . hasMore = false ;
2026-09-04 22:00:23 +03:30
} else if ( sync . phone . trim ()) {
body . phone = sync . phone . trim ();
2026-10-04 11:09:40 +03:30
syncListMode . value = 'filter' ;
syncListMeta . hasMore = false ;
2026-09-04 22:00:23 +03:30
} else if ( sync . name . trim ()) {
body . name = sync . name . trim ();
2026-10-04 11:09:40 +03:30
syncListMode . value = 'filter' ;
syncListMeta . hasMore = false ;
2026-09-04 22:00:23 +03:30
} else {
2026-09-05 09:33:12 +03:30
error . value = 'موبایل، نام، کد مشتری یا بازهٔ کد را وارد کنید.' ;
2026-09-04 22:00:23 +03:30
return ;
}
2026-10-04 11:09:40 +03:30
const { data } = await api . post < {
results : SyncResult [];
page ?: number ;
pageSize ?: number ;
fromCode ?: number ;
toCode ?: number ;
hasMore ?: boolean ;
note ?: string ;
} > (
2026-09-05 09:33:12 +03:30
'/integrations/tahesab/sync/search' ,
body ,
2026-10-04 11:09:40 +03:30
{ timeout : body . listAll ? 90 _000 : 60 _000 },
2026-09-05 09:33:12 +03:30
);
2026-09-04 22:00:23 +03:30
syncResults . value = data . results ?? [];
2026-10-04 11:09:40 +03:30
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 ;
}
2026-09-05 09:33:12 +03:30
if ( ! syncResults . value . length ) {
2026-10-04 11:09:40 +03:30
ok . value =
data . note ??
( syncListMode . value === 'listAll' && syncListPage . value > 1
? 'در این صفحه مشتریای نیست. صفحه قبل را ببینید یا بازه دستی بزنید.'
: 'نتیجهای یافت نشد.' );
2026-09-05 09:33:12 +03:30
} else {
const linked = syncResults . value . filter (( r ) => r . alreadyLinked ). length ;
2026-10-04 11:09:40 +03:30
const base =
` ${ syncResults . value . length } مشتری پیدا شد` +
( linked ? ` ( ${ linked } قبلاً لینک شده)` : '' );
ok . value = data . note ? ` ${ base } — ${ data . note } ` : base ;
2026-09-05 09:33:12 +03:30
}
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
}
}
2026-10-04 11:09:40 +03:30
function onSyncCodePageChange ( page : number ) {
void searchTahesab ( 'range' , page );
}
function onSyncTablePageChange ( page : number ) {
syncTablePage . value = page ;
}
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 ) {
2026-10-04 11:09:40 +03:30
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 ;
2026-09-05 10:30:54 +03:30
if (
! confirm (
2026-10-04 11:09:40 +03:30
`مشتریان صفحهٔ ${ page } (کد ${ from } تا ${ to } ) از تهحساب بهعنوان شخص وارد ${ brand . nameFa } میشوند. برای صفحات بعدی، پیجینیت را بزنید و دوباره وارد کنید. ادامه؟` ,
2026-09-05 10:30:54 +03:30
)
) {
return ;
}
2026-10-03 15:28:08 +03:30
body . listAll = true ;
2026-10-04 11:09:40 +03:30
body . page = page ;
body . pageSize = 2000 ;
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
}
}
2026-10-05 21:08:38 +03:30
syncBusy . value = true ;
importOverlay . open = true ;
importOverlay . progress = 0 ;
importOverlay . message = 'در حال آمادهسازی فهرست مشتریان…' ;
const totals = { imported : 0 , skipped : 0 , failed : 0 };
let firstError = '' ;
2026-09-05 09:33:12 +03:30
try {
2026-10-05 21:08:38 +03:30
let codes : number [];
if ( Array . isArray ( body . moshtariCodes )) {
codes = body . moshtariCodes as number [];
} else {
const { data } = await api . post < { results : SyncResult [] } > (
'/integrations/tahesab/sync/search' ,
body ,
{ timeout : 90 _000 },
);
const found = data . results ?? [];
totals . skipped += found . filter (( r ) => r . alreadyLinked ). length ;
codes = found
. filter (( r ) => r . moshtariCode != null && ! r . alreadyLinked )
. map (( r ) => r . moshtariCode as number );
}
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 } ` ;
2026-09-05 09:33:12 +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 {
2026-10-05 21:08:38 +03:30
importOverlay . open = false ;
importOverlay . progress = null ;
2026-09-05 09:33:12 +03:30
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" >
2026-10-04 11:09:40 +03:30
بازه ٔ دستی حداکثر ۲۰۰۰ کد است . اگر « از » و « تا » خالی باشد ، مشتریان صفحه به صفحه ( هر صفحه ۲۰۰۰ کد ) خوانده می شوند — بدون سقف ۵۰۰۰ .
2026-09-12 01:33:07 +03:30
</ 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"
2026-10-04 11:09:40 +03:30
@click ="searchTahesab('range', 1)"
2026-09-12 01:33:07 +03:30
>
2026-10-04 11:09:40 +03:30
نمایش بازه / همه
2026-09-12 01:33:07 +03:30
</ UiButton >
< UiButton
: disabled = "!sync.connectionId || syncBusy"
@click ="importBulk('range')"
>
2026-10-04 11:09:40 +03:30
وارد کردن این صفحه / بازه
2026-09-12 01:33:07 +03:30
</ UiButton >
</ div >
2026-10-04 11:09:40 +03:30
< 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 >
2026-09-12 01:33:07 +03:30
</ 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 >
2026-10-05 11:56:49 +03:30
< div class = "tc-row" >
< TableColumnsMenu table -key =" tahesab.customers " align = "end" />
</ div >
2026-09-12 01:33:07 +03:30
< div class = "crm-table-container" >
2026-10-05 20:00:21 +03:30
< table
v-table-cols = "'tahesab.customers'"
class = "crm-table"
: style = "{ '--row-start': (syncTablePage - 1) * SYNC_TABLE_PAGE_SIZE }"
>
2026-09-12 01:33:07 +03:30
< thead >
< tr >
< th > کد مشتری </ th >
< th > نام و نام خانوادگی </ th >
< th > تلفن </ th >
< th > وضعیت لینک </ th >
< th > عملیات </ th >
</ tr >
</ thead >
< tbody >
2026-10-04 11:09:40 +03:30
< tr v-for = "r in syncPagedResults" :key="String(r.moshtariCode)" >
2026-09-12 01:33:07 +03:30
< 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 >
2026-10-04 11:09:40 +03:30
< 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"
/>
2026-09-12 01:33:07 +03:30
</ 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 >
2026-10-05 11:56:49 +03:30
< div class = "tc-row" >
< TableColumnsMenu table -key =" tahesab.products " align = "end" />
</ div >
2026-09-21 21:25:34 +03:30
< div class = "crm-table-container" >
2026-10-05 11:56:49 +03:30
< table v-table-cols = "'tahesab.products'" class="crm-table" >
2026-09-21 21:25:34 +03:30
< 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-10-05 21:08:38 +03:30
< UiBusyOverlay
:open = "importOverlay.open"
:message = "importOverlay.message"
:progress = "importOverlay.progress"
/>
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 : 1 px 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 , # 5 eead4 );
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 : 8 px ;
height : 8 px ;
border - radius : var ( -- radius - full );
}
. health - status . ok {
color : var ( -- color - success - text , # 15803 d );
}
. health - status . ok . health - indicator {
background : var ( -- color - success - text , # 15803 d );
}
. 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 : 1 px solid var ( -- color - warning - border , # fef3c7 );
color : var ( -- color - warning - text , # 92400 e );
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 : 1 px 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-10-04 11:09:40 +03:30
. sync - code - pagination ,
. sync - table - pagination {
margin - top : var ( -- sp - 3 );
}
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 : 640 px ) {
. 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 >