feat(ui): standardize design system, modals, sidebar, and form inputs

- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
This commit is contained in:
s.esmaeili
2026-09-12 01:33:07 +03:30
parent 9a991fc1f1
commit 1ae87c97b7
87 changed files with 7844 additions and 5718 deletions
+431 -431
View File
@@ -9,6 +9,16 @@ import type {
UpdateTahesabConnectionRequest,
} from '@mento/shared';
import { brand } from '@mento/shared';
import {
UiBadge,
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSelect,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const loading = ref(false);
const saving = ref(false);
@@ -221,29 +231,27 @@ async function removeConn(id: string) {
}
}
async function searchTahesab(mode: 'filter' | 'range' = 'filter') {
async function searchTahesab(mode: 'range' | 'filter') {
error.value = '';
ok.value = '';
syncResults.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
error.value = 'ابتدا یک اتصال انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
syncBusy.value = true;
const body: Record<string, unknown> = {
connectionId: sync.connectionId,
};
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;
body.fromCode = 1;
body.toCode = 5000;
} else {
const fromCode = Number(fromRaw);
const toCode = Number(toRaw);
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
error.value = 'بازهٔ کد را درست وارد کنید، یا هر دو را خالی بگذارید تا همه نمایش داده شوند.';
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;
}
body.fromCode = fromCode;
@@ -328,22 +336,27 @@ async function importBulk(mode: 'results' | 'range') {
if (!fromRaw && !toRaw) {
if (
!confirm(
`همهٔ مشتریان ته‌حساب وارد ${brand.nameFa} می‌شوند (قبلاً لینک‌شده‌ها رد می‌شوند). ممکن است کمی طول بکشد. ادامه؟`,
`کل مشتریان از کد ۱ تا ۵۰۰۰ از ته‌حساب به‌عنوان شخص وارد ${brand.nameFa} می‌شوند. ادامه؟`,
)
) {
return;
}
body.listAll = true;
body.fromCode = 1;
body.toCode = 5000;
} else {
const fromCode = Number(fromRaw);
const toCode = Number(toRaw);
if (!Number.isFinite(fromCode) || !Number.isFinite(toCode)) {
error.value = 'بازهٔ کد را درست وارد کنید، یا هر دو را خالی بگذارید تا همه وارد شوند.';
const fromCode = fromRaw ? Number(fromRaw) : 1;
const toCode = toRaw ? Number(toRaw) : fromCode + 1999;
if (Number.isNaN(fromCode) || Number.isNaN(toCode) || fromCode < 1 || toCode < fromCode) {
error.value = 'بازه نامعتبر است.';
return;
}
if (toCode - fromCode > 2000) {
error.value = 'حداکثر طول بازه در هر درخواست ۲۰۰۰ کد است.';
return;
}
if (
!confirm(
`همهٔ مشتریان ته‌حساب با کد ${fromCode} تا ${toCode} وارد ${brand.nameFa} می‌شوند (قبلاً لینک‌شده‌ها رد می‌شوند). ادامه؟`,
`مشتریان با کد ${fromCode} تا ${toCode} از ته‌حساب وارد ${brand.nameFa} می‌شوند. ادامه؟`,
)
) {
return;
@@ -372,482 +385,469 @@ onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>اتصال ته‌حساب</h1>
<p class="lead">
هر نسخه/دیتابیس ته‌حساب یک اتصال جدا است (آدرس وب‌سرویس + توکن + نام DB).
اگر فقط یک اتصال دارید، همان به‌عنوان پیش‌فرض استفاده می‌شود.
</p>
</div>
</header>
<section class="crm-page tahesab-page" dir="rtl">
<UiPageHeader
title="اتصال نرم‌افزار مالی ته‌حساب"
description="همگام‌سازی اشخاص، مانده‌حساب‌ها و اسناد مالی بین ته‌حساب و نرم‌افزار مدیریت ارتباط با مشتریان"
/>
<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="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="banner warn"
class="crm-alert crm-alert-warning"
>
اتصال شما به‌خاطر گواهی 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>
<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 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>
</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>
</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>
<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>
<section class="card sync">
<h2>وارد کردن مشتریان از ته‌حساب</h2>
<p class="muted">
فقط «افزودن اتصال» مشتری‌ها را نمی‌آورد. باید با بازهٔ کد یا جستجو آن‌ها را از ته‌حساب
<strong>وارد</strong> کنید تا در «اشخاص و شرکت‌ها» دیده شوند. جزئیات مانده/اسناد بعد از لینک، در پروفایل شخص → تب دارایی است.
<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="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>
<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>
<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>
<div class="sync-sub-section">
<h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4>
<p class="sync-sub-desc">
در صورت خالی بودن «از» و «تا»، مشتریان کد ۱ تا ۵۰۰۰ بررسی می‌شوند (حداکثر ۲۰۰۰ کد در هر درخواست).
</p>
<div class="crm-form-grid-2">
<UiInput v-model="sync.fromCode" type="number" min="1" dir="ltr" placeholder="از کد (مثلاً ۱)" />
<UiInput v-model="sync.toCode" type="number" min="1" dir="ltr" placeholder="تا کد (مثلاً ۵۰۰)" />
</div>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('range')"
>
نمایش بازه
</UiButton>
<UiButton
:disabled="!sync.connectionId || syncBusy"
@click="importBulk('range')"
>
وارد کردن کل بازه
</UiButton>
</div>
</div>
<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>
<div class="sync-sub-section">
<h4 class="sync-subhead">۲) جستجوی تکی یا فیلتر مشخصات</h4>
<div class="crm-form-grid-3">
<UiInput v-model="sync.phone" dir="ltr" placeholder="موبایل یا تلفن..." />
<UiInput v-model="sync.name" placeholder="نام یا عنوان مشتری..." />
<UiInput v-model="sync.moshtariCode" type="number" dir="ltr" placeholder="کد مشتری ته‌حساب..." />
</div>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="searchTahesab('filter')"
>
جستجو در ته‌حساب
</UiButton>
<UiButton
:disabled="!sync.connectionId || syncBusy || !pendingImportCodes().length"
@click="importBulk('results')"
>
وارد کردن نتایج جدید ({{ pendingImportCodes().length }})
</UiButton>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">نتایج جستجو ({{ syncResults.length }})</h4>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th>کد مشتری</th>
<th>نام و نام خانوادگی</th>
<th>تلفن</th>
<th>وضعیت لینک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="r in syncResults" :key="String(r.moshtariCode)">
<td class="font-mono">{{ r.moshtariCode }}</td>
<td class="font-bold">{{ r.name || 'بدون نام' }}</td>
<td class="font-mono" dir="ltr">{{ r.tel }}</td>
<td>
<UiBadge v-if="r.alreadyLinked" variant="success">
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
</UiBadge>
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
</td>
<td>
<UiButton
v-if="!r.alreadyLinked"
size="sm"
:disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</UiButton>
<RouterLink
v-else-if="r.personId"
:to="{ name: 'person-detail', params: { id: r.personId } }"
>
<UiButton variant="ghost" size="sm">
مشاهده شخص
</UiButton>
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</UiCard>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 720px;
min-width: 0;
overflow-x: clip;
.tahesab-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
width: 100%;
max-width: 100%;
}
.page-head {
.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;
gap: 1rem;
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);
}
.head-copy {
min-width: 0;
.connection-row:hover {
border-color: var(--color-brand-300, #5eead4);
}
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 {
.connection-info {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
flex-direction: column;
gap: var(--sp-1);
}
.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: var(--radius);
min-height: var(--control-h);
padding: 0 0.7rem;
background: #fff;
color: #0f172a;
font: inherit;
width: 100%;
box-sizing: border-box;
}
select {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.7rem;
background: #fff;
color: #0f172a;
font: inherit;
width: 100%;
}
.check {
.connection-title-row {
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;
gap: var(--sp-2);
flex-wrap: wrap;
}
.btn {
.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;
justify-content: center;
min-height: var(--control-h);
padding: 0 0.9rem;
border-radius: var(--radius);
font-weight: 700;
border: 1px solid transparent;
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;
font: inherit;
user-select: none;
}
.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;
.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;
}
.sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.sync-grid.range {
grid-template-columns: repeat(2, 1fr);
}
.sync-actions {
.form-actions-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.subhead {
margin: 0.35rem 0 0;
font-size: 0.95rem;
color: #0f766e;
.sync-desc {
font-size: var(--fs-sm);
color: var(--color-text-secondary);
margin-top: 0;
}
.muted.small {
font-size: 0.85rem;
.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);
}
.linked {
display: inline-block;
margin-inline-start: 0.35rem;
font-size: 0.78rem;
.sync-subhead {
font-size: var(--fs-sm);
font-weight: 700;
color: #047857;
}
.sync-results {
list-style: none;
color: var(--color-text-primary);
margin: 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.sync-results li {
.sync-sub-desc {
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin: 0;
}
.sync-buttons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
gap: var(--sp-2);
flex-wrap: wrap;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
.sync-results-box {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-top: var(--sp-2);
}
.sync-grid {
grid-template-columns: 1fr;
}
.sync-results-title {
font-size: var(--fs-sm);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.row-actions,
.form-actions,
.sync-results li {
.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>