Chat settings in Super Admin and changing the chat title and position.

This commit is contained in:
2026-09-29 14:45:19 +03:30
parent 125976f3eb
commit 66af7e577c
14 changed files with 332 additions and 269 deletions
+8 -4
View File
@@ -13,7 +13,7 @@ const props = defineProps<{
const emit = defineEmits<{
start: [];
done: [ImportContactsResult];
done: [ImportContactsResult | Record<string, unknown>];
fail: [string];
settled: [];
}>();
@@ -64,9 +64,13 @@ async function postImport(file: File, map?: Record<string, string>) {
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
});
const { data } = await api.post<ImportContactsResult | Record<string, unknown>>(
props.importPath,
form,
{
headers: { 'Content-Type': undefined },
},
);
return data;
}
+25 -2
View File
@@ -64,7 +64,8 @@ const salesActive = computed(() =>
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const supportActive = computed(() => pathIn(['/tickets', '/conversations']));
const conversationsActive = computed(() => pathIn(['/conversations']));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(
() =>
route.path.startsWith('/settings/automations') ||
@@ -91,13 +92,13 @@ const settingsActive = computed(() =>
'/settings/tahesab',
'/settings/woocommerce',
'/settings/voip',
'/settings/conversations',
'/settings/faq',
]),
);
const moreSectionActive = computed(
() =>
notesActive.value ||
conversationsActive.value ||
supportActive.value ||
loyaltyActive.value ||
reportsActive.value ||
@@ -492,6 +493,18 @@ onUnmounted(() => {
</button>
</div>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
class="nav-item"
title="گفتگو"
:class="{ 'router-link-active': conversationsActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="chat" :size="collapsed ? 20 : 18" />
<span>گفتگو</span>
</RouterLink>
<RouterLink
to="/notes"
class="nav-item"
@@ -831,6 +844,16 @@ onUnmounted(() => {
<UiIcon name="document" :size="18" />
<span>یادداشت‌ها</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
class="nav-item"
:class="{ 'router-link-active': conversationsActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="chat" :size="18" />
<span>گفتگو</span>
</RouterLink>
<button
class="nav-item nav-fold-btn"
type="button"
-23
View File
@@ -247,12 +247,6 @@ export const NAV_HUBS: NavHubDef[] = [
shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
{
to: '/conversations',
title: 'گفتگو',
icon: 'chat',
module: 'conversations',
},
],
cards: [
{
@@ -263,14 +257,6 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'peach',
module: 'tickets',
},
{
to: '/conversations',
title: 'گفتگوی درون‌سازمانی',
desc: 'پیام خصوصی و گروه بین همکاران',
icon: 'chat',
tint: 'violet',
module: 'conversations',
},
],
},
{
@@ -484,15 +470,6 @@ export const NAV_HUBS: NavHubDef[] = [
module: 'voip',
admin: true,
},
{
to: '/settings/conversations',
title: 'گفتگوی درون‌سازمانی',
desc: 'اتصال به سرویس چت (Host و API Key)',
icon: 'chat',
tint: 'violet',
module: 'conversations',
admin: true,
},
{
to: '/settings/faq',
title: 'FAQ',
-6
View File
@@ -349,12 +349,6 @@ export const router = createRouter({
component: () => import('@/views/VoipSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/conversations',
name: 'conversations-settings',
component: () => import('@/views/ConversationsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/faq',
name: 'faq-settings',
@@ -1,147 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ConversationSettingsDto, UpdateConversationSettingsRequest } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const form = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
const apiKeySet = ref(false);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ConversationSettingsDto>('/conversations/settings');
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تنظیمات ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: form.chatHost.trim(),
uploadUrl: form.uploadUrl.trim() || undefined,
downloadUrl: form.downloadUrl.trim() || undefined,
};
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
const { data } = await api.patch<ConversationSettingsDto>('/conversations/settings', payload);
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
ok.value = 'ذخیره شد.';
} catch (e) {
if (axios.isAxiosError(e)) {
error.value =
(e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.';
} else {
error.value = 'ذخیره ناموفق بود.';
}
} finally {
saving.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<div class="page">
<UiPageHeader
title="تنظیمات گفتگوی درون‌سازمانی"
subtitle="اتصال به سرویس گفتگو (REST و WebSocket) — هر محیط CRM جداگانه."
>
<template #actions>
<RouterLink to="/conversations" class="link-back">بازگشت به گفتگو</RouterLink>
</template>
</UiPageHeader>
<p v-if="error" class="banner err">{{ error }}</p>
<p v-if="ok" class="banner ok">{{ ok }}</p>
<UiCard v-if="!loading" class="form-card">
<UiField label="Host سرویس چت" hint="بدون https — مثال: chat.montest.ir">
<UiInput v-model="form.chatHost" dir="ltr" autocomplete="off" />
</UiField>
<UiField
:label="apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key'"
hint="فقط روی سرور ذخیره می‌شود."
>
<UiInput v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
</UiField>
<UiField label="آدرس آپلود فایل">
<UiInput v-model="form.uploadUrl" dir="ltr" />
</UiField>
<UiField label="آدرس پایه دانلود فایل">
<UiInput v-model="form.downloadUrl" dir="ltr" />
</UiField>
<div class="actions">
<UiButton :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
</div>
</UiCard>
<p v-else class="muted">در حال بارگذاری…</p>
</div>
</template>
<style scoped>
.page {
max-width: 640px;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
}
.link-back {
font-size: 0.9rem;
color: var(--brand);
}
.banner {
padding: 0.65rem 0.85rem;
border-radius: 10px;
margin-bottom: 0.75rem;
}
.banner.err {
background: #fef2f2;
color: #b91c1c;
}
.banner.ok {
background: #ecfdf5;
color: #047857;
}
.form-card {
display: grid;
gap: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.muted {
color: var(--muted);
}
</style>
+1 -7
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
@@ -8,13 +7,11 @@ import type {
ConversationPeerDto,
ConversationRoomDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
import { useOrgChat } from '@/lib/useOrgChat';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
const auth = useAuthStore();
const access = ref<ConversationAccessDto | null>(null);
const rooms = ref<ConversationRoomDto[]>([]);
const peers = ref<ConversationPeerDto[]>([]);
@@ -188,11 +185,8 @@ onMounted(() => {
<h1>گفتگو</h1>
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیام‌رسان بیرونی.</p>
</div>
<div class="head-actions">
<div class="head-actions">
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
<RouterLink v-if="auth.user?.role === 'owner' || auth.user?.role === 'admin'" to="/settings/conversations" class="gear">
<UiIcon name="settings" :size="18" />
</RouterLink>
</div>
</header>
+43 -35
View File
@@ -17,6 +17,7 @@ import {
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import {
filterPhoneContacts,
@@ -120,7 +121,6 @@ const assignOpen = ref(false);
const stageOpen = ref(false);
const assignUserId = ref('');
const bulkStageId = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const importJob = ref<LeadImportJobDto | null>(null);
const createOpen = ref(false);
const createBusy = ref(false);
@@ -935,27 +935,28 @@ function startImportPoll(jobId: string) {
importTimer = setInterval(() => void pollImport(jobId), 1500);
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
function onExcelImportStart() {
busy.value = true;
error.value = '';
okMsg.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
importJob.value = job;
startImportPoll(job.id);
} catch {
error.value = '';
}
function onExcelImportDone(result: Record<string, unknown> | LeadImportJobDto) {
const job = result as LeadImportJobDto;
if (!job?.id) {
error.value = 'ورود فایل ناموفق بود.';
} finally {
busy.value = false;
return;
}
importJob.value = job;
startImportPoll(job.id);
}
function onExcelImportFail(message: string) {
error.value = message;
}
function onExcelImportSettled() {
busy.value = false;
}
function onPhoneImportStart() {
@@ -1034,22 +1035,20 @@ onBeforeUnmount(() => {
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<UiButton
variant="ghost"
size="sm"
<ExcelImportFlow
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@click.stop="fileInput?.click()"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls,.csv"
hidden
@change="onImportFile"
/>
<UiButton variant="ghost" size="sm" :disabled="busy">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</ExcelImportFlow>
<input
ref="logoFileInput"
type="file"
@@ -1565,9 +1564,18 @@ onBeforeUnmount(() => {
<template #action>
<div class="empty-actions">
<UiButton variant="primary" @click="openCreate">ثبت لید جدید</UiButton>
<UiButton v-if="isAdmin" variant="ghost" :disabled="busy" @click="fileInput?.click()">
ورود از فایل
</UiButton>
<ExcelImportFlow
v-if="isAdmin"
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<UiButton variant="ghost" :disabled="busy">ورود از فایل</UiButton>
</ExcelImportFlow>
</div>
</template>
</UiEmptyState>
@@ -4,10 +4,12 @@ import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
ConversationSettingsDto,
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
PlatformUserDto,
UpdateConversationSettingsRequest,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
@@ -55,6 +57,16 @@ const editForm = reactive({
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
const chatForm = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
const chatApiKeySet = ref(false);
const savingChat = ref(false);
const chatLoadedFor = ref<string | null>(null);
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -98,6 +110,7 @@ async function toggleOpen(org: PlatformOrganizationDto) {
openId.value = null;
detail.value = null;
editingId.value = null;
chatLoadedFor.value = null;
return;
}
openId.value = org.id;
@@ -108,11 +121,56 @@ async function toggleOpen(org: PlatformOrganizationDto) {
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
await loadChatSettings(org.id);
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
}
async function loadChatSettings(orgId: string) {
try {
const { data } = await platformApi.get<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
);
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatLoadedFor.value = orgId;
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
}
}
async function saveChatSettings(orgId: string) {
savingChat.value = true;
error.value = '';
success.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: chatForm.chatHost.trim(),
uploadUrl: chatForm.uploadUrl.trim() || undefined,
downloadUrl: chatForm.downloadUrl.trim() || undefined,
};
if (chatForm.apiKey.trim()) payload.apiKey = chatForm.apiKey.trim();
const { data } = await platformApi.patch<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
payload,
);
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
success.value = 'تنظیمات گفتگو ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
} finally {
savingChat.value = false;
}
}
function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id;
editForm.name = org.name;
@@ -469,6 +527,51 @@ onMounted(() => {
</div>
</div>
<div class="chat-settings">
<div class="modules-head">
<strong>تنظیمات گفتگو</strong>
<button
class="btn"
type="button"
:disabled="savingChat || chatLoadedFor !== org.id"
@click="saveChatSettings(org.id)"
>
{{ savingChat ? 'ذخیره…' : 'ذخیره گفتگو' }}
</button>
</div>
<p class="chat-hint">
Host، API Key و آدرس آپلود/دانلود — فقط از سوپرادمین و برای همین محیط.
</p>
<div class="chat-grid">
<label>
<span>Host سرویس چت</span>
<input
v-model="chatForm.chatHost"
dir="ltr"
placeholder="chat.example.ir"
autocomplete="off"
/>
</label>
<label>
<span>{{ chatApiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
<input
v-model="chatForm.apiKey"
type="password"
dir="ltr"
autocomplete="new-password"
/>
</label>
<label>
<span>آدرس آپلود فایل</span>
<input v-model="chatForm.uploadUrl" dir="ltr" />
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input v-model="chatForm.downloadUrl" dir="ltr" />
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
@@ -716,6 +819,33 @@ label span {
border: 1px solid var(--line);
}
.chat-settings {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
background: #f5f3ff;
border: 1px solid #e9e5ff;
}
.chat-hint {
margin: 0;
font-size: 0.82rem;
color: var(--muted);
}
.chat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 720px) {
.chat-grid {
grid-template-columns: 1fr;
}
}
.modules-head,
.mod-group-head {
display: flex;