Montify stores integration: multi-store connections, customer/order/transaction sync, webhooks

This commit is contained in:
2026-10-10 15:59:12 +03:30
parent fa3fe0d364
commit e82bd2b2df
18 changed files with 3712 additions and 0 deletions
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { MontifyOrderDto, MontifyPersonStoreDto } from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{ personId: string }>();
const auth = useAuthStore();
const enabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('montify');
});
const stores = ref<MontifyPersonStoreDto[]>([]);
const openOrder = ref<string | null>(null);
async function load() {
stores.value = [];
if (!enabled.value || !props.personId) return;
try {
const { data } = await api.get<MontifyPersonStoreDto[]>(
`/integrations/montify/people/${props.personId}`,
);
stores.value = data;
} catch {
stores.value = [];
}
}
function amount(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : toFaDigits(v.toLocaleString('en-US'));
}
function date(iso: string | null): string {
return iso ? formatJalaliNumeric(new Date(iso)) : '—';
}
function orderTotal(o: MontifyOrderDto): number | null {
return o.discountTotalPrice ?? o.totalPrice;
}
watch(() => props.personId, load, { immediate: true });
</script>
<template>
<section v-if="stores.length" class="stores">
<h3>حساب در فروشگاه‌ها</h3>
<article v-for="s in stores" :key="s.connectionId + s.externalCustomerId" class="store">
<header>
<strong>{{ s.connectionLabel }}</strong>
<span class="muted">
شناسه مشتری {{ toFaDigits(s.externalCustomerId) }}
<template v-if="s.username"> · {{ s.username }}</template>
<template v-if="s.customerType"> · {{ s.customerType }}</template>
</span>
</header>
<dl>
<div>
<dt>موجودی کیف پول (ریال)</dt>
<dd>{{ amount(s.walletBalance) }}</dd>
</div>
<div>
<dt>موجودی طلا (گرم)</dt>
<dd>{{ amount(s.goldWalletBalance) }}</dd>
</div>
<div>
<dt>تعداد سفارش</dt>
<dd>{{ amount(s.totalOrders) }}</dd>
</div>
<div>
<dt>جمع خرید (ریال)</dt>
<dd>{{ amount(s.totalSpent) }}</dd>
</div>
<div v-if="s.engagement">
<dt>ذخیره / پسند / نظر</dt>
<dd>
{{ toFaDigits(s.engagement.saved) }} / {{ toFaDigits(s.engagement.likes) }} /
{{ toFaDigits(s.engagement.comments) }}
</dd>
</div>
</dl>
<div v-if="s.orders.length" class="block">
<h4>سفارش‌ها</h4>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>کد سفارش</th>
<th>وضعیت</th>
<th>پرداخت</th>
<th>مبلغ (ریال)</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<template v-for="o in s.orders" :key="o.id">
<tr class="clickable" @click="openOrder = openOrder === o.id ? null : o.id">
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
<td>{{ o.orderStatus || '—' }}</td>
<td>{{ o.paymentStatus || '—' }}</td>
<td>{{ amount(orderTotal(o)) }}</td>
<td>{{ date(o.createdAt) }}</td>
</tr>
<tr v-if="openOrder === o.id" class="detail">
<td colspan="5">
<ul v-if="o.items.length">
<li v-for="(it, idx) in o.items" :key="idx">
{{ it.productName || 'قلم' }}
— {{ amount(it.qty) }} عدد
<template v-if="it.weight"> — {{ amount(it.weight) }} گرم</template>
— {{ amount(it.totalPrice ?? it.price) }}
</li>
</ul>
<p v-if="o.paymentType" class="muted">نوع پرداخت: {{ o.paymentType }}</p>
<p v-if="o.totalDiscounts" class="muted">تخفیف: {{ amount(o.totalDiscounts) }}</p>
<p v-if="o.note" class="muted">یادداشت: {{ o.note }}</p>
<p v-if="o.cancelReason" class="muted">دلیل لغو: {{ o.cancelReason }}</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div v-if="s.transactions.length" class="block">
<h4>تراکنش‌ها</h4>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>نوع</th>
<th>مقدار</th>
<th>واحد</th>
<th>وضعیت</th>
<th>کد پیگیری</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="t in s.transactions" :key="t.id">
<td>{{ t.type || '—' }}</td>
<td>{{ amount(t.amount) }}</td>
<td>{{ t.currency || '—' }}</td>
<td>{{ t.status || '—' }}</td>
<td dir="ltr">{{ t.referenceCode || '—' }}</td>
<td>{{ date(t.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="muted small">آخرین همگام‌سازی {{ date(s.syncedAt) }}</p>
</article>
</section>
</template>
<style scoped>
.stores {
display: grid;
gap: 0.6rem;
margin-bottom: 1rem;
}
h3 {
margin: 0;
font-size: 0.95rem;
}
h4 {
margin: 0 0 0.35rem;
font-size: 0.85rem;
color: #334155;
}
.store {
border: 1px solid #eef2f7;
border-radius: var(--radius);
padding: 0.75rem;
display: grid;
gap: 0.6rem;
}
.store header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin: 0;
}
dt {
color: #64748b;
font-size: 0.78rem;
}
dd {
margin: 0.15rem 0 0;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
text-align: right;
padding: 0.4rem;
border-bottom: 1px solid #f1f5f9;
}
th {
color: #64748b;
font-size: 0.76rem;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background: #f8fafc;
}
.detail td {
background: #f8fafc;
}
.detail ul {
margin: 0;
padding-inline-start: 1.1rem;
}
.muted {
color: #64748b;
font-size: 0.82rem;
margin: 0.2rem 0 0;
}
.small {
font-size: 0.78rem;
margin: 0;
}
</style>
+1
View File
@@ -96,6 +96,7 @@ const settingsActive = computed(() =>
'/settings/currencies',
'/settings/tahesab',
'/settings/woocommerce',
'/settings/montify',
'/settings/voip',
'/settings/faq',
]),
+9
View File
@@ -461,6 +461,15 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'woocommerce',
admin: true,
},
{
to: '/settings/montify',
title: 'فروشگاه‌های مونتیفای',
desc: 'اتصال چند فروشگاه: مشتری، سفارش و محصول',
icon: 'box',
tint: 'violet',
module: 'montify',
admin: true,
},
{
to: '/settings/voip',
title: 'تلفن سازمانی',
+6
View File
@@ -405,6 +405,12 @@ export const router = createRouter({
component: () => import('@/views/WooCommerceSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/montify',
name: 'montify-settings',
component: () => import('@/views/MontifySettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/voip',
name: 'voip-settings',
+509
View File
@@ -0,0 +1,509 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateMontifyConnectionRequest,
MontifyConnectionDto,
MontifyOrderDto,
MontifyTestResultDto,
UpdateMontifyConnectionRequest,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
const busyId = ref('');
const error = ref('');
const ok = ref('');
const items = ref<MontifyConnectionDto[]>([]);
const orders = ref<MontifyOrderDto[]>([]);
const editingId = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const emptyForm = () => ({
label: '',
baseUrl: '',
token: '',
storeId: '',
});
const form = reactive(emptyForm());
const anyRunning = computed(() => items.value.some((c) => c.syncStatus === 'running'));
function errMsg(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const m = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(m)) return m.join('، ');
if (m) return String(m);
}
return fallback;
}
async function load(silent = false) {
if (!silent) loading.value = true;
try {
const [connRes, orderRes] = await Promise.all([
api.get<MontifyConnectionDto[]>('/integrations/montify/connections'),
api.get<MontifyOrderDto[]>('/integrations/montify/orders'),
]);
items.value = connRes.data;
orders.value = orderRes.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول فروشگاه‌های مونتیفای برای این محیط فعال نیست.';
} else if (!silent) {
error.value = 'بارگذاری ناموفق بود.';
}
} finally {
loading.value = false;
}
if (anyRunning.value && !pollTimer) {
pollTimer = setInterval(() => void load(true), 4000);
} else if (!anyRunning.value && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function resetForm() {
Object.assign(form, emptyForm());
editingId.value = null;
}
function startEdit(c: MontifyConnectionDto) {
editingId.value = c.id;
Object.assign(form, {
label: c.label,
baseUrl: c.baseUrl,
token: '',
storeId: c.storeId,
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function save() {
error.value = '';
ok.value = '';
if (!form.label.trim() || !form.baseUrl.trim()) {
error.value = 'نام و Base URL فروشگاه الزامی است.';
return;
}
if (!editingId.value && !form.token.trim()) {
error.value = 'توکن فروشگاه الزامی است.';
return;
}
saving.value = true;
try {
if (editingId.value) {
const payload: UpdateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
storeId: form.storeId.trim(),
...(form.token.trim() ? { token: form.token.trim() } : {}),
};
await api.patch(`/integrations/montify/connections/${editingId.value}`, payload);
ok.value = 'تغییرات ذخیره شد.';
} else {
const payload: CreateMontifyConnectionRequest = {
label: form.label.trim(),
baseUrl: form.baseUrl.trim(),
token: form.token.trim(),
storeId: form.storeId.trim() || undefined,
};
await api.post('/integrations/montify/connections', payload);
ok.value = 'فروشگاه اضافه شد. حالا «تست اتصال» را بزنید.';
}
resetForm();
await load();
} catch (e) {
error.value = errMsg(e, 'ذخیره ناموفق بود.');
} finally {
saving.value = false;
}
}
async function run(id: string, fn: () => Promise<void>) {
busyId.value = id;
error.value = '';
ok.value = '';
try {
await fn();
} catch (e) {
error.value = errMsg(e, 'عملیات ناموفق بود.');
} finally {
busyId.value = '';
}
}
function test(c: MontifyConnectionDto) {
return run(c.id, async () => {
const { data } = await api.post<MontifyTestResultDto>(
`/integrations/montify/connections/${c.id}/test`,
);
ok.value =
data.totalCustomers !== null
? `اتصال «${c.label}» موفق بود — ${toFaDigits(data.totalCustomers)} مشتری در فروشگاه.`
: `اتصال «${c.label}» موفق بود.`;
await load(true);
});
}
function sync(c: MontifyConnectionDto, mode: 'incremental' | 'full') {
if (
mode === 'full' &&
!confirm('همگام‌سازی کامل همه مشتریان و سفارش‌ها را دوباره می‌خواند و ممکن است چند دقیقه طول بکشد. ادامه؟')
) {
return;
}
return run(c.id, async () => {
await api.post(`/integrations/montify/connections/${c.id}/sync`, null, { params: { mode } });
ok.value = 'همگام‌سازی در پس‌زمینه شروع شد.';
await load(true);
});
}
function toggleWebhook(c: MontifyConnectionDto) {
return run(c.id, async () => {
if (c.webhookRegistered) {
if (!confirm('وب‌هوک این فروشگاه حذف شود؟')) return;
await api.delete(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک حذف شد.';
} else {
await api.post(`/integrations/montify/connections/${c.id}/webhook`);
ok.value = 'وب‌هوک در فروشگاه ثبت شد؛ تغییرات از این به بعد لحظه‌ای می‌رسد.';
}
await load(true);
});
}
function toggleEnabled(c: MontifyConnectionDto) {
return run(c.id, async () => {
await api.patch(`/integrations/montify/connections/${c.id}`, { enabled: !c.enabled });
await load(true);
});
}
function remove(c: MontifyConnectionDto) {
if (!confirm(`اتصال «${c.label}» حذف شود؟ اشخاص و فاکتورهای واردشده می‌مانند.`)) return;
return run(c.id, async () => {
await api.delete(`/integrations/montify/connections/${c.id}`);
if (editingId.value === c.id) resetForm();
await load(true);
});
}
function formatAmount(v: number | null): string {
return v === null ? '—' : toFaDigits(v.toLocaleString('en-US'));
}
function statusLabel(c: MontifyConnectionDto): string {
if (!c.enabled) return 'غیرفعال';
if (c.syncStatus === 'running') return 'در حال همگام‌سازی…';
if (c.syncStatus === 'error') return 'خطا';
if (c.lastHealthOk === false) return 'اتصال ناموفق';
if (c.lastSyncAt) return 'فعال';
return 'هنوز همگام نشده';
}
function statusClass(c: MontifyConnectionDto): string {
if (!c.enabled) return 'off';
if (c.syncStatus === 'running') return 'run';
if (c.syncStatus === 'error' || c.lastHealthOk === false) return 'bad';
return 'good';
}
onMounted(() => void load());
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<div class="page">
<UiPageHeader
title="فروشگاه‌های مونتیفای"
description="اتصال فقط‌خواندنی چند فروشگاه؛ مشتری‌ها به اشخاص وصل می‌شوند و سفارش، آدرس، تراکنش و تعاملات هر مشتری در پرونده‌اش دیده می‌شود"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load()">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'">
<p class="hint">
فروشنده در پنل مونتیفای از «تنظیمات ← یکپارچه‌سازی‌ها ← CRM» توکن صادر می‌کند و
Base URL، Token و Store ID را به شما می‌دهد. توکن فقط روی سرور و به‌صورت رمزشده نگهداری می‌شود.
</p>
<div class="form-grid">
<UiField label="نام فروشگاه">
<UiInput v-model="form.label" placeholder="مثلاً فروشگاه شعبه ۱" />
</UiField>
<UiField label="Base URL">
<UiInput v-model="form.baseUrl" dir="ltr" placeholder="https://store1.montify.com/api/v1" />
</UiField>
<UiField :label="editingId ? 'توکن جدید (خالی = بدون تغییر)' : 'Token'">
<UiInput v-model="form.token" type="password" dir="ltr" placeholder="mk_live_…" />
</UiField>
<UiField label="Store ID">
<UiInput v-model="form.storeId" dir="ltr" placeholder="b2f1e4a0-…" />
</UiField>
</div>
<div class="form-actions">
<UiButton :loading="saving" @click="save">
{{ editingId ? 'ذخیره تغییرات' : 'افزودن فروشگاه' }}
</UiButton>
<UiButton v-if="editingId" variant="secondary" @click="resetForm">انصراف</UiButton>
</div>
</UiCard>
<UiCard title="فروشگاه‌های متصل">
<UiEmptyState
v-if="!loading && !items.length"
title="فروشگاهی وصل نشده"
description="اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
/>
<div v-else class="list">
<article v-for="c in items" :key="c.id" class="item">
<div class="info">
<div class="title-row">
<strong>{{ c.label }}</strong>
<span class="badge" :class="statusClass(c)">{{ statusLabel(c) }}</span>
</div>
<p class="muted ltr">{{ c.baseUrl }}</p>
<p class="muted">
توکن {{ c.tokenMasked }}
<template v-if="c.storeId"> · Store ID {{ c.storeId }}</template>
· {{ toFaDigits(c.linkedCustomers) }} مشتری
· {{ toFaDigits(c.orders) }} سفارش
</p>
<p class="muted">
{{
c.lastSyncAt
? `آخرین همگام‌سازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
: 'هنوز همگام نشده'
}}
· وب‌هوک {{ c.webhookRegistered ? 'فعال' : 'ثبت نشده' }}
<template v-if="c.lastWebhookAt">
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
</template>
</p>
<p v-if="c.lastSyncResult" class="muted">
{{ c.syncStatus === 'running' ? 'پیشرفت' : 'نتیجه' }}:
{{ toFaDigits(c.lastSyncResult.customersFetched) }} مشتری خوانده شد
({{ toFaDigits(c.lastSyncResult.customersCreated) }} شخص جدید)،
{{ toFaDigits(c.lastSyncResult.ordersFetched) }} سفارش،
{{ toFaDigits(c.lastSyncResult.transactionsFetched) }} تراکنش
<template v-if="c.lastSyncResult.errors.length">
· {{ toFaDigits(c.lastSyncResult.errors.length) }} خطا
</template>
</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<div class="actions">
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
تست اتصال
</UiButton>
<UiButton
size="sm"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'incremental')"
>
همگام‌سازی
</UiButton>
<UiButton
size="sm"
variant="secondary"
:disabled="!c.enabled || c.syncStatus === 'running'"
@click="sync(c, 'full')"
>
همگام‌سازی کامل
</UiButton>
<UiButton size="sm" variant="secondary" :disabled="!c.enabled" @click="toggleWebhook(c)">
{{ c.webhookRegistered ? 'حذف وب‌هوک' : 'ثبت وب‌هوک' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="startEdit(c)">ویرایش</UiButton>
<UiButton size="sm" variant="secondary" @click="toggleEnabled(c)">
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="remove(c)">حذف</UiButton>
</div>
</article>
</div>
</UiCard>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
description="همگام‌سازی را بزنید یا وب‌هوک را ثبت کنید."
/>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>فروشگاه</th>
<th>کد سفارش</th>
<th>مشتری</th>
<th>وضعیت سفارش</th>
<th>وضعیت پرداخت</th>
<th>مبلغ (ریال)</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<td>{{ o.connectionLabel }}</td>
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
<td>
<RouterLink v-if="o.personId" :to="`/contacts/people/${o.personId}`">
{{ o.personName || 'مشاهده' }}
</RouterLink>
<span v-else>—</span>
</td>
<td>{{ o.orderStatus || '—' }}</td>
<td>{{ o.paymentStatus || '—' }}</td>
<td>{{ formatAmount(o.discountTotalPrice ?? o.totalPrice) }}</td>
<td>{{ o.createdAt ? formatJalaliNumeric(new Date(o.createdAt)) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.hint,
.muted {
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
margin: 0;
}
.ltr {
direction: ltr;
text-align: right;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.info {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
}
.badge.good {
background: #dcfce7;
color: #166534;
}
.badge.bad {
background: #fee2e2;
color: #991b1b;
}
.badge.run {
background: #e0f2fe;
color: #075985;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-content: start;
justify-content: end;
max-width: 340px;
}
.warn {
color: #b45309;
font-size: 0.85rem;
margin: 0;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: right;
padding: 0.55rem;
border-bottom: 1px solid #eef2f7;
}
th {
color: #64748b;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.form-grid {
grid-template-columns: 1fr;
}
.item {
flex-direction: column;
}
.actions {
max-width: none;
justify-content: start;
}
}
</style>
+2
View File
@@ -23,6 +23,7 @@ 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 MontifyStoresCard from '@/components/person-assets/MontifyStoresCard.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
@@ -937,6 +938,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
</div>