850 lines
23 KiB
Vue
850 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue';
|
||
import { RouterLink } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import type {
|
||
CreateTahesabConnectionRequest,
|
||
TahesabConnectionDto,
|
||
UpdateTahesabConnectionRequest,
|
||
} from '@mento/shared';
|
||
|
||
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[]>([]);
|
||
|
||
function pendingImportCodes() {
|
||
return syncResults.value
|
||
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
|
||
.map((r) => r.moshtariCode as number);
|
||
}
|
||
|
||
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;
|
||
} 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: 'filter' | 'range' = 'filter') {
|
||
error.value = '';
|
||
ok.value = '';
|
||
syncResults.value = [];
|
||
if (!sync.connectionId) {
|
||
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
||
return;
|
||
}
|
||
try {
|
||
syncBusy.value = true;
|
||
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) {
|
||
body.listAll = true;
|
||
} else {
|
||
const fromCode = Number(fromRaw);
|
||
const toCode = Number(toRaw);
|
||
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
|
||
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;
|
||
}
|
||
const { data } = await api.post<{ results: SyncResult[] }>(
|
||
'/integrations/tahesab/sync/search',
|
||
body,
|
||
{ timeout: 60_000 },
|
||
);
|
||
syncResults.value = data.results ?? [];
|
||
if (!syncResults.value.length) {
|
||
ok.value = 'نتیجهای یافت نشد.';
|
||
} else {
|
||
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
|
||
ok.value = `${syncResults.value.length} مشتری پیدا شد` + (linked ? ` (${linked} قبلاً لینک شده)` : '');
|
||
}
|
||
} catch (e) {
|
||
error.value = apiErr(e, 'جستجو ناموفق');
|
||
} finally {
|
||
syncBusy.value = false;
|
||
}
|
||
}
|
||
|
||
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} مشتری جدید از تهحساب بهعنوان شخص در منتو ساخته و لینک میشوند. ادامه؟`,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
body.moshtariCodes = codes;
|
||
} else {
|
||
const fromRaw = String(sync.fromCode ?? '').trim();
|
||
const toRaw = String(sync.toCode ?? '').trim();
|
||
if (!fromRaw && !toRaw) {
|
||
if (
|
||
!confirm(
|
||
'همهٔ مشتریان تهحساب وارد منتو میشوند (قبلاً لینکشدهها رد میشوند). ممکن است کمی طول بکشد. ادامه؟',
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
body.listAll = true;
|
||
} else {
|
||
const fromCode = Number(fromRaw);
|
||
const toCode = Number(toRaw);
|
||
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
|
||
error.value = 'بازهٔ کد را درست وارد کنید، یا هر دو را خالی بگذارید تا همه وارد شوند.';
|
||
return;
|
||
}
|
||
if (
|
||
!confirm(
|
||
`همهٔ مشتریان تهحساب با کد ${fromCode} تا ${toCode} وارد منتو میشوند (قبلاً لینکشدهها رد میشوند). ادامه؟`,
|
||
)
|
||
) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
onMounted(load);
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page">
|
||
<header class="page-head">
|
||
<div class="head-copy">
|
||
<h1>اتصال تهحساب</h1>
|
||
<p class="lead">
|
||
هر نسخه/دیتابیس تهحساب یک اتصال جدا است (آدرس وبسرویس + توکن + نام DB).
|
||
اگر فقط یک اتصال دارید، همان بهعنوان پیشفرض استفاده میشود.
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||
|
||
<div
|
||
v-if="items.some((r) => !r.allowInsecureTls && isTlsError(r.lastError))"
|
||
class="banner warn"
|
||
>
|
||
اتصال شما بهخاطر گواهی SSL خودامضا قطع است. روی همان کارت اتصال،
|
||
<strong>فعالسازی SSL خودامضا</strong> را بزنید؛ بعد دوباره «نمایش بازه» را امتحان کنید.
|
||
</div>
|
||
|
||
<div v-if="!loading && items.length" class="list">
|
||
<article v-for="row in items" :key="row.id" class="card row">
|
||
<div class="row-main">
|
||
<strong>{{ row.label }}</strong>
|
||
<span class="muted">{{ row.baseUrl }}</span>
|
||
<span class="meta">
|
||
DB: {{ row.dbName }} · توکن: {{ row.tokenMasked }}
|
||
<template v-if="row.isDefault"> · پیشفرض</template>
|
||
<template v-if="!row.enabled"> · غیرفعال</template>
|
||
<template v-if="row.allowInsecureTls"> · SSL خودامضا مجاز</template>
|
||
</span>
|
||
<span
|
||
v-if="row.lastHealthAt"
|
||
class="health"
|
||
:class="row.lastHealthOk ? 'ok' : 'bad'"
|
||
>
|
||
آخرین تست:
|
||
{{ row.lastHealthOk ? 'موفق' : 'ناموفق' }}
|
||
<template v-if="row.lastError"> — {{ row.lastError }}</template>
|
||
</span>
|
||
</div>
|
||
<div class="row-actions">
|
||
<button
|
||
v-if="!row.allowInsecureTls && isTlsError(row.lastError)"
|
||
type="button"
|
||
class="btn primary"
|
||
@click="enableInsecureTls(row.id)"
|
||
>
|
||
فعالسازی SSL خودامضا
|
||
</button>
|
||
<button type="button" class="btn ghost" @click="startEdit(row)">ویرایش</button>
|
||
<button type="button" class="btn ghost" @click="testConn(row.id)">تست</button>
|
||
<button type="button" class="btn danger" @click="removeConn(row.id)">حذف</button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<form class="card" @submit.prevent="save">
|
||
<h2>{{ editingId ? 'ویرایش اتصال' : 'افزودن اتصال' }}</h2>
|
||
<div class="field">
|
||
<label>برچسب</label>
|
||
<input v-model="form.label" required maxlength="120" placeholder="مثلاً شعبه بازار" />
|
||
</div>
|
||
<div class="field">
|
||
<label>آدرس وبسرویس (base URL)</label>
|
||
<input
|
||
v-model="form.baseUrl"
|
||
required
|
||
dir="ltr"
|
||
placeholder="https://api-test.tahesab.ir یا https://IP:8081"
|
||
@blur="onBaseUrlBlur"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label>نام دیتابیس (DBname)</label>
|
||
<input v-model="form.dbName" required dir="ltr" placeholder="DB یا TahesabDB" />
|
||
</div>
|
||
<div class="field">
|
||
<label>
|
||
توکن
|
||
<span v-if="editingId" class="muted">(خالی بگذارید تا عوض نشود)</span>
|
||
</label>
|
||
<input
|
||
v-model="form.token"
|
||
:required="!editingId"
|
||
type="password"
|
||
dir="ltr"
|
||
autocomplete="new-password"
|
||
placeholder="Bearer token"
|
||
/>
|
||
</div>
|
||
<label class="check">
|
||
<input v-model="form.isDefault" type="checkbox" />
|
||
اتصال پیشفرض
|
||
</label>
|
||
<label class="check">
|
||
<input v-model="form.enabled" type="checkbox" />
|
||
فعال
|
||
</label>
|
||
<label class="check highlight">
|
||
<input v-model="form.allowInsecureTls" type="checkbox" />
|
||
پذیرش گواهی SSL خودامضا (برای IP داخلی / https بدون گواهی معتبر — معمولاً لازم است)
|
||
</label>
|
||
<div class="form-actions">
|
||
<button class="btn primary" type="submit" :disabled="saving">
|
||
{{ saving ? 'در حال ذخیره…' : editingId ? 'بهروزرسانی' : 'افزودن' }}
|
||
</button>
|
||
<button v-if="editingId" type="button" class="btn ghost" @click="resetForm">انصراف</button>
|
||
</div>
|
||
</form>
|
||
|
||
<section class="card sync">
|
||
<h2>وارد کردن مشتریان از تهحساب</h2>
|
||
<p class="muted">
|
||
فقط «افزودن اتصال» مشتریها را نمیآورد. باید با بازهٔ کد یا جستجو آنها را از تهحساب
|
||
<strong>وارد</strong> کنید تا در «اشخاص و شرکتها» دیده شوند. جزئیات مانده/اسناد بعد از لینک، در پروفایل شخص → تب دارایی است.
|
||
</p>
|
||
<div class="field">
|
||
<label>اتصال</label>
|
||
<select 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>
|
||
</select>
|
||
</div>
|
||
|
||
<h3 class="subhead">۱) وارد کردن دستهای با بازهٔ کد مشتری</h3>
|
||
<p class="muted small">
|
||
اگر «از» و «تا» خالی باشند، مشتریان با کد ۱ تا ۵۰۰۰ از تهحساب لیست میشوند.
|
||
برای کدهای بالاتر بازه را دستی بگذارید (حداکثر ۲۰۰۰ کد در هر درخواست محدود).
|
||
</p>
|
||
<div class="sync-grid range">
|
||
<input v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (خالی = همه)" />
|
||
<input v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (خالی = همه)" />
|
||
</div>
|
||
<div class="sync-actions">
|
||
<button
|
||
type="button"
|
||
class="btn ghost"
|
||
:disabled="!sync.connectionId || syncBusy"
|
||
@click="searchTahesab('range')"
|
||
>
|
||
نمایش بازه
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn primary"
|
||
:disabled="!sync.connectionId || syncBusy"
|
||
@click="importBulk('range')"
|
||
>
|
||
وارد کردن کل بازه
|
||
</button>
|
||
</div>
|
||
|
||
<h3 class="subhead">۲) جستجوی تکی</h3>
|
||
<div class="sync-grid">
|
||
<input v-model="sync.phone" dir="ltr" placeholder="موبایل" />
|
||
<input v-model="sync.name" placeholder="نام" />
|
||
<input v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری" />
|
||
</div>
|
||
<div class="sync-actions">
|
||
<button
|
||
type="button"
|
||
class="btn ghost"
|
||
:disabled="!sync.connectionId || syncBusy"
|
||
@click="searchTahesab('filter')"
|
||
>
|
||
جستجو
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn primary"
|
||
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
|
||
@click="importBulk('results')"
|
||
>
|
||
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
|
||
</button>
|
||
</div>
|
||
|
||
<ul v-if="syncResults.length" class="sync-results">
|
||
<li v-for="r in syncResults" :key="String(r.moshtariCode)">
|
||
<span>
|
||
{{ r.moshtariCode }} — {{ r.name || 'بدون نام' }}
|
||
<span class="muted">{{ r.tel }}</span>
|
||
<span v-if="r.alreadyLinked" class="linked">لینکشده{{ r.personName ? ` · ${r.personName}` : '' }}</span>
|
||
</span>
|
||
<button
|
||
v-if="!r.alreadyLinked"
|
||
type="button"
|
||
class="btn"
|
||
:disabled="!r.moshtariCode || syncBusy"
|
||
@click="importOne(r.moshtariCode!)"
|
||
>
|
||
وارد کردن
|
||
</button>
|
||
<RouterLink
|
||
v-else-if="r.personId"
|
||
class="btn ghost"
|
||
:to="{ name: 'person-detail', params: { id: r.personId } }"
|
||
>
|
||
مشاهده شخص
|
||
</RouterLink>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: grid;
|
||
gap: 1rem;
|
||
max-width: 720px;
|
||
min-width: 0;
|
||
overflow-x: clip;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.head-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0 0 0.3rem;
|
||
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
|
||
font-weight: 800;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.lead,
|
||
.muted {
|
||
margin: 0;
|
||
color: #64748b;
|
||
}
|
||
|
||
.list {
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.card {
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 18px;
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||
padding: 1.1rem;
|
||
}
|
||
|
||
.row {
|
||
grid-template-columns: 1fr auto;
|
||
align-items: start;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.row-main {
|
||
display: grid;
|
||
gap: 0.25rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.meta {
|
||
font-size: 0.85rem;
|
||
color: #64748b;
|
||
}
|
||
|
||
.health.ok {
|
||
color: #15803d;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.health.bad {
|
||
color: #b91c1c;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.row-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
label {
|
||
color: #64748b;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
input[type='text'],
|
||
input:not([type]),
|
||
input[type='password'],
|
||
input[type='url'],
|
||
input[type='number'] {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
padding: 0.55rem 0.7rem;
|
||
background: #fff;
|
||
color: #0f172a;
|
||
font: inherit;
|
||
width: 100%;
|
||
}
|
||
|
||
select {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
padding: 0.55rem 0.7rem;
|
||
background: #fff;
|
||
color: #0f172a;
|
||
font: inherit;
|
||
width: 100%;
|
||
}
|
||
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.check.highlight {
|
||
padding: 0.65rem 0.75rem;
|
||
border-radius: 12px;
|
||
background: #fff7ed;
|
||
border: 1px solid #fdba74;
|
||
font-weight: 700;
|
||
color: #9a3412;
|
||
}
|
||
|
||
.banner.warn {
|
||
padding: 0.85rem 1rem;
|
||
border-radius: 14px;
|
||
background: #fff7ed;
|
||
border: 1px solid #fdba74;
|
||
color: #9a3412;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 42px;
|
||
padding: 0.55rem 0.9rem;
|
||
border-radius: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
.btn.primary {
|
||
background: #14b8a6;
|
||
color: #fff;
|
||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||
}
|
||
|
||
.btn.ghost {
|
||
background: #fff;
|
||
color: #0f766e;
|
||
border-color: #99f6e4;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.btn.danger {
|
||
background: #fff;
|
||
color: #b91c1c;
|
||
border-color: #fecaca;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.error {
|
||
margin: 0;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.ok {
|
||
margin: 0;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sync-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.sync-grid.range {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
.sync-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.subhead {
|
||
margin: 0.35rem 0 0;
|
||
font-size: 0.95rem;
|
||
color: #0f766e;
|
||
}
|
||
|
||
.muted.small {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.linked {
|
||
display: inline-block;
|
||
margin-inline-start: 0.35rem;
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
color: #047857;
|
||
}
|
||
|
||
.sync-results {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.sync-results li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem 0;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.sync-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.row-actions,
|
||
.form-actions,
|
||
.sync-results li {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
</style>
|