Add Tahesab jewelry accounting integration with multi-DB connections.

Encrypt tokens server-side, sync customers both ways, show balances/documents on the person Assets tab, and push sanads with idempotent Factor_Code logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-04 22:00:23 +03:30
co-authored by Cursor
parent d2f66d11fc
commit 4b47b753c3
26 changed files with 13784 additions and 8 deletions
+14 -2
View File
@@ -21,6 +21,7 @@ import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -40,7 +41,7 @@ import type {
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'info';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -65,6 +66,7 @@ const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
@@ -345,7 +347,13 @@ function formatSize(bytes: number) {
function readTab() {
const q = route.query.tab;
if (q === 'activities' || q === 'deals' || q === 'cards' || q === 'info') {
if (
q === 'activities' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info'
) {
tab.value = q;
} else {
tab.value = 'activities';
@@ -803,6 +811,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<PersonAssetsPanel :person-id="id" />
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">
+502
View File
@@ -0,0 +1,502 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
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,
});
const syncResults = ref<
Array<{ moshtariCode: number | null; name: string; tel: string }>
>([]);
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 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 ?? 'تست اتصال ناموفق بود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تست ناموفق')
: 'تست ناموفق';
}
}
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() {
error.value = '';
ok.value = '';
syncResults.value = [];
try {
const body: Record<string, unknown> = {
connectionId: sync.connectionId,
};
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: Array<{ moshtariCode: number | null; name: string; tel: string }>;
}>('/integrations/tahesab/sync/search', body);
syncResults.value = data.results ?? [];
if (!syncResults.value.length) ok.value = 'نتیجه‌ای یافت نشد.';
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'جستجو ناموفق')
: 'جستجو ناموفق';
}
}
async function importOne(moshtariCode: number) {
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{ personId: string }>(
'/integrations/tahesab/sync/import',
{ connectionId: sync.connectionId, moshtariCode },
);
ok.value = `وارد شد — شخص ${data.personId}`;
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'وارد کردن ناموفق')
: 'وارد کردن ناموفق';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header>
<p class="eyebrow">تنظیمات</p>
<h1>اتصال ته‌حساب</h1>
<p class="lead">
هر نسخه/دیتابیس ته‌حساب یک اتصال جدا است (آدرس وب‌سرویس + توکن + نام DB).
اگر فقط یک اتصال دارید، همان به‌عنوان پیش‌فرض استفاده می‌شود.
</p>
</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="!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>
</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 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"
/>
</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">
<input v-model="form.allowInsecureTls" type="checkbox" />
پذیرش گواهی SSL خودامضا (فقط شبکه داخلی)
</label>
<div class="form-actions">
<button class="btn" 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">
مشتری را در یک اتصال جستجو کنید و به‌عنوان شخص جدید وارد CRM کنید یا به شخص موجود لینک دهید.
</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>
<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>
<button type="button" class="btn ghost" :disabled="!sync.connectionId" @click="searchTahesab">
جستجو
</button>
<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>
<button
type="button"
class="btn"
:disabled="!r.moshtariCode"
@click="importOne(r.moshtariCode!)"
>
وارد کردن
</button>
</li>
</ul>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 720px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
.lead,
.muted {
color: var(--muted);
}
.list {
display: grid;
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.75rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.row {
grid-template-columns: 1fr auto;
align-items: start;
gap: 0.75rem;
}
.row-main {
display: grid;
gap: 0.25rem;
}
.meta {
font-size: 0.85rem;
color: var(--muted);
}
.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: var(--muted);
font-size: 0.9rem;
}
input[type='text'],
input:not([type]),
input[type='password'],
input[type='url'] {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
}
.check {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--text);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn {
border: 0;
border-radius: 10px;
padding: 0.55rem 0.9rem;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.btn.danger {
background: transparent;
color: #b91c1c;
border: 1px solid #fecaca;
}
.error {
color: #b91c1c;
}
.ok {
color: #15803d;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr;
}
}
.sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.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 var(--line);
}
select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
background: var(--bg);
color: var(--text);
}
@media (max-width: 640px) {
.sync-grid {
grid-template-columns: 1fr;
}
}
</style>