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

850 lines
23 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 { 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>