Files
CRM_Montzar/apps/web/src/views/MontifySettingsView.vue
T

573 lines
17 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.
<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,
MontifyDiagnoseDto,
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);
});
}
const diagnoses = ref<Record<string, MontifyDiagnoseDto>>({});
function diagnose(c: MontifyConnectionDto) {
return run(c.id, async () => {
const { data } = await api.post<MontifyDiagnoseDto>(
`/integrations/montify/connections/${c.id}/diagnose`,
);
diagnoses.value = { ...diagnoses.value, [c.id]: data };
});
}
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`, {}, { 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 v-if="diagnoses[c.id]" class="diag">
<p class="muted">
بررسی مسیرها
<template v-if="diagnoses[c.id]!.customerId">
(با مشتری {{ diagnoses[c.id]!.customerId }})
</template>
</p>
<ul>
<li v-for="ch in diagnoses[c.id]!.checks" :key="ch.path" :class="ch.ok ? 'ok-line' : 'bad-line'">
<strong>{{ ch.ok ? '✓' : '✗' }} {{ ch.name }}</strong>
<span class="code">{{ ch.status || 'خطای شبکه' }}</span>
<span class="ltr path">{{ ch.path }}</span>
<span class="info">{{ ch.info }}</span>
</li>
</ul>
</div>
</div>
<div class="actions">
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
تست اتصال
</UiButton>
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="diagnose(c)">
بررسی مسیرها
</UiButton>
<UiButton
size="sm"
:disabled="!c.enabled"
@click="sync(c, 'incremental')"
>
همگام‌سازی
</UiButton>
<UiButton
size="sm"
variant="secondary"
:disabled="!c.enabled"
@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;
}
.diag {
margin-top: 0.4rem;
padding: 0.6rem;
background: #f8fafc;
border-radius: var(--radius);
}
.diag ul {
list-style: none;
margin: 0.3rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
font-size: 0.82rem;
}
.diag li {
display: grid;
gap: 0.1rem;
}
.diag .code {
font-weight: 700;
}
.diag .path,
.diag .info {
color: #64748b;
word-break: break-all;
}
.ok-line strong {
color: #166534;
}
.bad-line strong {
color: #991b1b;
}
.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>