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:
@@ -0,0 +1,868 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import type {
|
||||
PersonAccountingLinkDto,
|
||||
PersonAssetsDto,
|
||||
TahesabConnectionDto,
|
||||
TahesabSanadType,
|
||||
} from '@mento/shared';
|
||||
import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab';
|
||||
|
||||
const props = defineProps<{
|
||||
personId: string;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const connections = ref<TahesabConnectionDto[]>([]);
|
||||
const links = ref<PersonAccountingLinkDto[]>([]);
|
||||
const connectionId = ref('');
|
||||
const assets = ref<PersonAssetsDto | null>(null);
|
||||
const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب');
|
||||
const asnadLoading = ref(false);
|
||||
const asnadItems = ref<Record<string, unknown>[]>([]);
|
||||
const fromDate = ref('');
|
||||
const toDate = ref('');
|
||||
const manualCode = ref('');
|
||||
const matchCandidates = ref<
|
||||
Array<{ moshtariCode: number; name: string; tel: string; matchBy: string }>
|
||||
>([]);
|
||||
const sanadBusy = ref(false);
|
||||
|
||||
const sanad = ref({
|
||||
docType: 'buy_sale_gold' as TahesabSanadType,
|
||||
year: 1405,
|
||||
month: 1,
|
||||
day: 1,
|
||||
sharh: '',
|
||||
vazn: 0,
|
||||
ayar: 750,
|
||||
buyOrSale: 0 as 0 | 1,
|
||||
mazaneh: 0,
|
||||
mazanehIsGeram: 0 as 0 | 1,
|
||||
isAbshode: 1 as 0 | 1,
|
||||
mablaghKol: 0,
|
||||
count: 1,
|
||||
nameSekeh: '',
|
||||
isBedehiOrTalab: 0 as 0 | 1,
|
||||
mablagh: 0,
|
||||
baabat: 'CRM',
|
||||
isVoroodOrKhorooj: 0 as 0 | 1,
|
||||
angNumber: '',
|
||||
nameAz: '',
|
||||
isDaryaftOrPardakht: 1 as 0 | 1,
|
||||
nameBank: '',
|
||||
codeRahgiri: '',
|
||||
});
|
||||
|
||||
const currentLink = computed(() =>
|
||||
links.value.find((l) => l.connectionId === connectionId.value) ?? null,
|
||||
);
|
||||
|
||||
const hasMultipleConnections = computed(() => connections.value.length > 1);
|
||||
|
||||
const goldBalance = computed(() => assets.value?.goldBalance ?? null);
|
||||
const rialBalance = computed(() => assets.value?.rialBalance ?? null);
|
||||
|
||||
const mandehGroups = computed(() => {
|
||||
const items = assets.value?.mandehDetail ?? [];
|
||||
const groups: Record<string, typeof items> = {
|
||||
مالی: [],
|
||||
'فلزات/وزنی': [],
|
||||
سکه: [],
|
||||
کالا: [],
|
||||
ارز: [],
|
||||
سایر: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const name = item.Name || '';
|
||||
if (/ریال|تومان|مالی|پول|وجه/i.test(name)) groups['مالی']!.push(item);
|
||||
else if (/سکه/i.test(name)) groups['سکه']!.push(item);
|
||||
else if (/ارز|دلار|یورو/i.test(name)) groups['ارز']!.push(item);
|
||||
else if (/طلا|نقره|پلاتین|وزن|آبشده|شمش/i.test(name))
|
||||
groups['فلزات/وزنی']!.push(item);
|
||||
else if (/کار|اتیکت|کالا/i.test(name)) groups['کالا']!.push(item);
|
||||
else groups['سایر']!.push(item);
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.filter(([, list]) => list.length > 0)
|
||||
.map(([label, list]) => ({ label, items: list }));
|
||||
});
|
||||
|
||||
function balanceClass(v: number | null) {
|
||||
if (v == null || v === 0) return '';
|
||||
return v > 0 ? 'credit' : 'debt';
|
||||
}
|
||||
|
||||
function balanceLabel(v: number | null) {
|
||||
if (v == null || v === 0) return '';
|
||||
return v > 0 ? 'بستانکار' : 'بدهکار';
|
||||
}
|
||||
|
||||
function formatAmt(v: number | null, digits = 3) {
|
||||
if (v == null) return '—';
|
||||
return Math.abs(v).toLocaleString('fa-IR', {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function formatMandehAmount(amount: string | number, unit: string) {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return String(amount);
|
||||
const abs = Math.abs(n);
|
||||
if (unit === 'ریال') return formatMoney(abs, 'IRR');
|
||||
return abs.toLocaleString('fa-IR', { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function defaultJalaliRange() {
|
||||
// تقریبی: کاربر میتواند عوض کند؛ پیشفرض بازه یکماهه متنی
|
||||
const now = new Date();
|
||||
// بدون وابستگی سنگین: مقادیر نمونه قابل ویرایش
|
||||
toDate.value = `${now.getFullYear() - 621}-01-01`;
|
||||
// بهتر: از API تاریخ نخواهیم؛ فیلدها را خالیتر با placeholder بگذاریم
|
||||
const y = 1405;
|
||||
fromDate.value = `${y}-01-01`;
|
||||
toDate.value = `${y}-12-29`;
|
||||
sanad.value.year = y;
|
||||
sanad.value.month = 1;
|
||||
sanad.value.day = 1;
|
||||
}
|
||||
|
||||
async function loadConnectionsAndLinks() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [connRes, linkRes] = await Promise.all([
|
||||
api.get<TahesabConnectionDto[]>('/integrations/tahesab/connections/public'),
|
||||
api.get<PersonAccountingLinkDto[]>(
|
||||
`/integrations/tahesab/people/${props.personId}/links`,
|
||||
),
|
||||
]);
|
||||
connections.value = connRes.data.filter((c) => c.enabled);
|
||||
links.value = linkRes.data;
|
||||
const def =
|
||||
connections.value.find((c) => c.isDefault) ?? connections.value[0];
|
||||
if (!connectionId.value && def) connectionId.value = def.id;
|
||||
else if (
|
||||
connectionId.value &&
|
||||
!connections.value.some((c) => c.id === connectionId.value)
|
||||
) {
|
||||
connectionId.value = def?.id ?? '';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'بارگذاری اتصال تهحساب ناموفق بود.',
|
||||
)
|
||||
: 'بارگذاری اتصال تهحساب ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAssets() {
|
||||
if (!connectionId.value || !currentLink.value) {
|
||||
assets.value = null;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<PersonAssetsDto>(
|
||||
`/integrations/tahesab/people/${props.personId}/assets`,
|
||||
{ params: { connectionId: connectionId.value, refresh: '1' } },
|
||||
);
|
||||
assets.value = data;
|
||||
} catch (e) {
|
||||
assets.value = null;
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'دریافت مانده ناموفق بود.',
|
||||
)
|
||||
: 'دریافت مانده ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAsnad() {
|
||||
if (!connectionId.value || !currentLink.value) return;
|
||||
if (!fromDate.value || !toDate.value) {
|
||||
error.value = 'تاریخ از و تا را وارد کنید (شمسی YYYY-MM-DD).';
|
||||
return;
|
||||
}
|
||||
asnadLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<{ items: Record<string, unknown>[] }>(
|
||||
`/integrations/tahesab/people/${props.personId}/asnad`,
|
||||
{
|
||||
params: {
|
||||
connectionId: connectionId.value,
|
||||
from: fromDate.value,
|
||||
to: toDate.value,
|
||||
},
|
||||
},
|
||||
);
|
||||
asnadItems.value = data.items ?? [];
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'دریافت اسناد ناموفق بود.',
|
||||
)
|
||||
: 'دریافت اسناد ناموفق بود.';
|
||||
} finally {
|
||||
asnadLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatch() {
|
||||
if (!connectionId.value) return;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const { data } = await api.post<{
|
||||
status: string;
|
||||
candidates: typeof matchCandidates.value;
|
||||
link: PersonAccountingLinkDto | null;
|
||||
}>(`/integrations/tahesab/people/${props.personId}/match`, {
|
||||
connectionId: connectionId.value,
|
||||
autoLinkIfUnique: true,
|
||||
});
|
||||
matchCandidates.value = data.candidates ?? [];
|
||||
if (data.status === 'linked') {
|
||||
ok.value = 'مشتری با موفقیت لینک شد.';
|
||||
await loadConnectionsAndLinks();
|
||||
await refreshAssets();
|
||||
} else if (data.status === 'none') {
|
||||
error.value =
|
||||
'مشتری در تهحساب پیدا نشد. کد را دستی وارد کنید یا «ثبت در تهحساب» را بزنید.';
|
||||
} else if (data.status === 'ambiguous') {
|
||||
error.value = 'چند مشتری پیدا شد — یکی را انتخاب کنید.';
|
||||
} else if (data.status === 'unique') {
|
||||
ok.value = 'یک مشتری پیدا شد — برای لینک تأیید کنید.';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'تطبیق ناموفق')
|
||||
: 'تطبیق ناموفق';
|
||||
}
|
||||
}
|
||||
|
||||
async function linkCode(code: number) {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post(`/integrations/tahesab/people/${props.personId}/links`, {
|
||||
connectionId: connectionId.value,
|
||||
moshtariCode: code,
|
||||
});
|
||||
ok.value = `لینک به کد ${code} انجام شد.`;
|
||||
matchCandidates.value = [];
|
||||
await loadConnectionsAndLinks();
|
||||
await refreshAssets();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'لینک ناموفق')
|
||||
: 'لینک ناموفق';
|
||||
}
|
||||
}
|
||||
|
||||
async function manualLink() {
|
||||
const code = Number(manualCode.value);
|
||||
if (!Number.isFinite(code) || code <= 0) {
|
||||
error.value = 'کد مشتری معتبر وارد کنید.';
|
||||
return;
|
||||
}
|
||||
await linkCode(code);
|
||||
}
|
||||
|
||||
async function createInTahesab() {
|
||||
if (!connectionId.value) return;
|
||||
if (!confirm('این شخص بهعنوان مشتری جدید در تهحساب ثبت شود؟')) return;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post(
|
||||
`/integrations/tahesab/people/${props.personId}/create-in-tahesab`,
|
||||
{ connectionId: connectionId.value },
|
||||
);
|
||||
ok.value = 'مشتری در تهحساب ثبت و لینک شد.';
|
||||
await loadConnectionsAndLinks();
|
||||
await refreshAssets();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق',
|
||||
)
|
||||
: 'ثبت ناموفق';
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
if (!connectionId.value || !currentLink.value) return;
|
||||
if (!confirm('لینک این شخص با تهحساب قطع شود؟')) return;
|
||||
await api.delete(
|
||||
`/integrations/tahesab/people/${props.personId}/links/${connectionId.value}`,
|
||||
);
|
||||
assets.value = null;
|
||||
await loadConnectionsAndLinks();
|
||||
}
|
||||
|
||||
async function pushSanad() {
|
||||
if (!connectionId.value || !currentLink.value) return;
|
||||
if (!confirm('سند در تهحساب ثبت شود؟ این عمل مالی است و قابل بازگشت از CRM نیست.')) {
|
||||
return;
|
||||
}
|
||||
sanadBusy.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const body = {
|
||||
connectionId: connectionId.value,
|
||||
...sanad.value,
|
||||
};
|
||||
const { data } = await api.post<{
|
||||
idempotent?: boolean;
|
||||
log: { factorCode: string; status: string; tahesabOk: string | null };
|
||||
}>(`/integrations/tahesab/people/${props.personId}/sanad`, body);
|
||||
ok.value = data.idempotent
|
||||
? `سند تکراری نبود — همان Factor_Code قبلی: ${data.log.factorCode}`
|
||||
: `سند ثبت شد. کد: ${data.log.tahesabOk ?? data.log.factorCode}`;
|
||||
await refreshAssets();
|
||||
if (activeTab.value === 'جزئیات حساب') await loadAsnad();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string | string[] })?.message ??
|
||||
'ثبت سند ناموفق',
|
||||
)
|
||||
: 'ثبت سند ناموفق';
|
||||
} finally {
|
||||
sanadBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(connectionId, async () => {
|
||||
matchCandidates.value = [];
|
||||
asnadItems.value = [];
|
||||
if (currentLink.value) await refreshAssets();
|
||||
else assets.value = null;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
defaultJalaliRange();
|
||||
await loadConnectionsAndLinks();
|
||||
if (currentLink.value) await refreshAssets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="assets">
|
||||
<p v-if="!connections.length && !loading" class="empty">
|
||||
اتصال تهحساب تعریف نشده است.
|
||||
<RouterLink to="/settings/tahesab">تنظیمات تهحساب</RouterLink>
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="toolbar">
|
||||
<label v-if="hasMultipleConnections" class="conn-pick">
|
||||
<span>اتصال</span>
|
||||
<select v-model="connectionId">
|
||||
<option v-for="c in connections" :key="c.id" :value="c.id">
|
||||
{{ c.label }} ({{ c.dbName }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-else-if="connections[0]" class="muted">
|
||||
اتصال: {{ connections[0].label }} · DB {{ connections[0].dbName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-bar card">
|
||||
<template v-if="currentLink">
|
||||
<div>
|
||||
<strong>لینکشده</strong>
|
||||
<span class="muted">
|
||||
کد تهحساب: {{ currentLink.moshtariCode }} · روش:
|
||||
{{ currentLink.matchMethod }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn ghost" @click="refreshAssets">بروزرسانی مانده</button>
|
||||
<button type="button" class="btn ghost" @click="unlink">قطع لینک</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">این مشتری هنوز به تهحساب لینک نشده است.</p>
|
||||
<div class="actions wrap">
|
||||
<button type="button" class="btn" @click="runMatch">تطبیق خودکار</button>
|
||||
<button type="button" class="btn ghost" @click="createInTahesab">ثبت در تهحساب</button>
|
||||
<div class="manual">
|
||||
<input
|
||||
v-model="manualCode"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="کد مشتری"
|
||||
dir="ltr"
|
||||
/>
|
||||
<button type="button" class="btn ghost" @click="manualLink">لینک دستی</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="matchCandidates.length" class="candidates">
|
||||
<li v-for="c in matchCandidates" :key="c.moshtariCode">
|
||||
<button type="button" class="btn ghost" @click="linkCode(c.moshtariCode)">
|
||||
{{ c.moshtariCode }} — {{ c.name || 'بدون نام' }}
|
||||
<span class="muted">({{ c.tel || c.matchBy }})</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<p v-if="loading" class="muted">در حال دریافت اطلاعات…</p>
|
||||
|
||||
<template v-if="currentLink && assets">
|
||||
<section class="summary card">
|
||||
<h3>دارایی</h3>
|
||||
<div class="balance-row">
|
||||
<span>دارایی طلایی</span>
|
||||
<div class="val" :class="balanceClass(goldBalance)" dir="ltr">
|
||||
<small v-if="balanceLabel(goldBalance)">{{ balanceLabel(goldBalance) }}</small>
|
||||
{{ formatAmt(goldBalance) }}
|
||||
<em>(گرم)</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="balance-row">
|
||||
<span>دارایی ریالی</span>
|
||||
<div class="val" :class="balanceClass(rialBalance)" dir="ltr">
|
||||
<small v-if="balanceLabel(rialBalance)">{{ balanceLabel(rialBalance) }}</small>
|
||||
{{ rialBalance != null ? formatMoney(Math.abs(rialBalance), 'IRR') : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="assets.totalGoldEquiv != null" class="balance-row">
|
||||
<span>دارایی کل به طلا</span>
|
||||
<div class="val" :class="balanceClass(assets.totalGoldEquiv)" dir="ltr">
|
||||
{{ formatAmt(assets.totalGoldEquiv) }}
|
||||
<em>(گرم)</em>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted tiny">آخرین دریافت: {{ assets.fetchedAt }}</p>
|
||||
</section>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in ['مانده حساب', 'جزئیات حساب', 'ثبت سند'] as const"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: activeTab === t }"
|
||||
@click="activeTab = t"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'مانده حساب'" class="detail">
|
||||
<template v-for="group in mandehGroups" :key="group.label">
|
||||
<div class="group-title">
|
||||
<span class="line" />
|
||||
<strong>{{ group.label }}</strong>
|
||||
<span class="line" />
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in group.items"
|
||||
:key="idx"
|
||||
class="mandeh-card card"
|
||||
>
|
||||
<div class="name">{{ item.Name }}:</div>
|
||||
<div class="vals">
|
||||
<template v-for="(v, vi) in item.values" :key="vi">
|
||||
<span :class="Number(v.amount) >= 0 ? 'credit' : 'debt'">
|
||||
{{ formatMandehAmount(v.amount, v.unit) }}
|
||||
</span>
|
||||
<span class="unit">{{ v.unit }}</span>
|
||||
<span class="st">{{ v.status === 'بس' ? 'بد' : 'بس' }}</span>
|
||||
<span v-if="vi < item.values.length - 1" class="sep">|</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!mandehGroups.length" class="muted">جزئیات ماندهای نیست.</p>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'جزئیات حساب'" class="asnad">
|
||||
<div class="date-row">
|
||||
<label>
|
||||
از
|
||||
<input v-model="fromDate" dir="ltr" placeholder="1405-01-01" />
|
||||
</label>
|
||||
<label>
|
||||
تا
|
||||
<input v-model="toDate" dir="ltr" placeholder="1405-12-29" />
|
||||
</label>
|
||||
<button type="button" class="btn" :disabled="asnadLoading" @click="loadAsnad">
|
||||
{{ asnadLoading ? '…' : 'نمایش اسناد' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="asnad-grid">
|
||||
<article
|
||||
v-for="(item, idx) in asnadItems"
|
||||
:key="String(item.Factor_Code ?? item.ID ?? idx)"
|
||||
class="sanad-card card"
|
||||
>
|
||||
<div class="sc-head">
|
||||
<span>شماره سند: {{ item.Sh_Factor ?? item.Sh_factor ?? '—' }}</span>
|
||||
<span>تاریخ: {{ item.Tarikh ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="sc-row gold">
|
||||
<span>نوع</span>
|
||||
<strong>{{ item.NO ?? '—' }}</strong>
|
||||
</div>
|
||||
<div class="sc-row gold">
|
||||
<span>وزن / عیار</span>
|
||||
<strong dir="ltr">{{ item.Vazn ?? '—' }} / {{ item.Ayar ?? '—' }}</strong>
|
||||
</div>
|
||||
<div class="sc-row cash">
|
||||
<span>مالی</span>
|
||||
<strong dir="ltr">{{ item.Mali ?? '—' }}</strong>
|
||||
</div>
|
||||
<div class="sc-row">
|
||||
<span>شرح</span>
|
||||
<span>{{ item.Sharh1 ?? item.Sharh ?? '—' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="!asnadLoading && !asnadItems.length" class="muted">سندی نیست.</p>
|
||||
</div>
|
||||
|
||||
<form v-show="activeTab === 'ثبت سند'" class="sanad-form card" @submit.prevent="pushSanad">
|
||||
<p class="warn">
|
||||
ثبت سند مستقیماً در تهحساب انجام میشود. قبل از تأیید مقادیر را دوباره بررسی کنید.
|
||||
</p>
|
||||
<label>
|
||||
نوع سند
|
||||
<select v-model="sanad.docType">
|
||||
<option v-for="(label, key) in TAHSAB_SANAD_LABELS" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="triple">
|
||||
<label>سال<input v-model.number="sanad.year" type="number" required /></label>
|
||||
<label>ماه<input v-model.number="sanad.month" type="number" min="1" max="12" required /></label>
|
||||
<label>روز<input v-model.number="sanad.day" type="number" min="1" max="31" required /></label>
|
||||
</div>
|
||||
<label>شرح<input v-model="sanad.sharh" /></label>
|
||||
|
||||
<template v-if="sanad.docType === 'buy_sale_gold' || sanad.docType === 'buy_sale_sekeh'">
|
||||
<div class="triple">
|
||||
<label>وزن<input v-model.number="sanad.vazn" type="number" step="any" required /></label>
|
||||
<label>عیار<input v-model.number="sanad.ayar" type="number" step="any" required /></label>
|
||||
<label>
|
||||
خرید/فروش
|
||||
<select v-model.number="sanad.buyOrSale">
|
||||
<option :value="1">خرید</option>
|
||||
<option :value="0">فروش</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="triple">
|
||||
<label>مظنه<input v-model.number="sanad.mazaneh" type="number" step="any" required /></label>
|
||||
<label>مبلغ کل<input v-model.number="sanad.mablaghKol" type="number" step="any" required /></label>
|
||||
<label v-if="sanad.docType === 'buy_sale_gold'">
|
||||
آبشده؟
|
||||
<select v-model.number="sanad.isAbshode">
|
||||
<option :value="1">آبشده</option>
|
||||
<option :value="0">متفرقه</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="sanad.docType === 'buy_sale_sekeh'">
|
||||
<label>تعداد<input v-model.number="sanad.count" type="number" step="any" required /></label>
|
||||
<label>نام سکه<input v-model="sanad.nameSekeh" required /></label>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="sanad.docType === 'talab_bedehi'">
|
||||
<label>
|
||||
نوع
|
||||
<select v-model.number="sanad.isBedehiOrTalab">
|
||||
<option :value="0">طلب</option>
|
||||
<option :value="1">بدهی</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
|
||||
<label>وزن (اختیاری)<input v-model.number="sanad.vazn" type="number" step="any" /></label>
|
||||
<label>بابت<input v-model="sanad.baabat" /></label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="sanad.docType === 'vkh_gold'">
|
||||
<div class="triple">
|
||||
<label>وزن<input v-model.number="sanad.vazn" type="number" step="any" required /></label>
|
||||
<label>عیار<input v-model.number="sanad.ayar" type="number" step="any" required /></label>
|
||||
<label>
|
||||
ورود/خروج
|
||||
<select v-model.number="sanad.isVoroodOrKhorooj">
|
||||
<option :value="0">ورود</option>
|
||||
<option :value="1">خروج</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>شماره انگ<input v-model="sanad.angNumber" /></label>
|
||||
<label>آزمایشگاه<input v-model="sanad.nameAz" /></label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="sanad.docType === 'vkh_bank'">
|
||||
<label>
|
||||
دریافت/پرداخت
|
||||
<select v-model.number="sanad.isDaryaftOrPardakht">
|
||||
<option :value="1">دریافت از مشتری</option>
|
||||
<option :value="0">پرداخت به مشتری</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>مبلغ<input v-model.number="sanad.mablagh" type="number" step="any" required /></label>
|
||||
<label>نام بانک<input v-model="sanad.nameBank" required /></label>
|
||||
<label>کد رهگیری<input v-model="sanad.codeRahgiri" /></label>
|
||||
</template>
|
||||
|
||||
<button class="btn" type="submit" :disabled="sanadBusy">
|
||||
{{ sanadBusy ? 'در حال ثبت…' : 'ثبت سند در تهحساب' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assets {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
.toolbar,
|
||||
.link-bar,
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.actions.wrap {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.conn-pick {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.manual {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.candidates {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
}
|
||||
.summary h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.balance-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
.balance-row:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.val {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.val em {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.val small {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.credit {
|
||||
color: #15803d;
|
||||
}
|
||||
.debt {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
}
|
||||
.group-title .line {
|
||||
flex: 1;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.mandeh-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.vals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.unit,
|
||||
.st,
|
||||
.sep,
|
||||
.muted,
|
||||
.tiny {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.date-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.date-row label,
|
||||
.sanad-form label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.asnad-grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
}
|
||||
.sc-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.sc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sc-row.gold {
|
||||
background: color-mix(in srgb, #ca8a04 12%, transparent);
|
||||
}
|
||||
.sc-row.cash {
|
||||
background: color-mix(in srgb, #16a34a 12%, transparent);
|
||||
}
|
||||
.sanad-form {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.triple {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.warn {
|
||||
margin: 0;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.8rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.ok {
|
||||
color: #15803d;
|
||||
}
|
||||
.empty a {
|
||||
color: var(--accent);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.triple {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,7 @@ const settingsPaths = [
|
||||
'/settings/custom-fields',
|
||||
'/settings/products',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
'/settings/faq',
|
||||
];
|
||||
|
||||
@@ -327,6 +328,7 @@ onUnmounted(() => {
|
||||
<RouterLink to="/settings/lead-forms" @click="closeMenu">فرمساز سرنخ</RouterLink>
|
||||
<RouterLink to="/settings/custom-fields" @click="closeMenu">فیلدهای سفارشی</RouterLink>
|
||||
<RouterLink to="/settings/currencies" @click="closeMenu">ارزها</RouterLink>
|
||||
<RouterLink to="/settings/tahesab" @click="closeMenu">تهحساب</RouterLink>
|
||||
<RouterLink to="/settings/faq" @click="closeMenu">FAQ</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** برچسب انواع سند تهحساب — کپی runtime برای Vite (بدون named export از shared) */
|
||||
export const TAHSAB_SANAD_LABELS: Record<string, string> = {
|
||||
buy_sale_gold: 'خرید/فروش آبشده',
|
||||
buy_sale_sekeh: 'خرید/فروش سکه',
|
||||
talab_bedehi: 'طلب/بدهی',
|
||||
vkh_gold: 'واریز/خروج طلا',
|
||||
vkh_bank: 'واریز/خروج بانکی',
|
||||
};
|
||||
@@ -204,6 +204,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/CurrencySettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/tahesab',
|
||||
name: 'tahesab-settings',
|
||||
component: () => import('@/views/TahesabSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/faq',
|
||||
name: 'faq-settings',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user