Chat settings in Super Admin and changing the chat title and position.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
@@ -6,7 +6,6 @@ import { OrgModuleGuard } from '../billing/org-module.guard';
|
|||||||
import {
|
import {
|
||||||
CreateConversationDmDto,
|
CreateConversationDmDto,
|
||||||
CreateConversationGroupDto,
|
CreateConversationGroupDto,
|
||||||
UpdateConversationSettingsDto,
|
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { ConversationsService } from './conversations.service';
|
import { ConversationsService } from './conversations.service';
|
||||||
|
|
||||||
@@ -21,19 +20,6 @@ export class ConversationsController {
|
|||||||
return this.conversations.getAccess(user.userId, user.organizationId, user.role);
|
return this.conversations.getAccess(user.userId, user.organizationId, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('settings')
|
|
||||||
settings(@CurrentUser() user: { organizationId: string; role: string }) {
|
|
||||||
return this.conversations.getSettings(user.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('settings')
|
|
||||||
updateSettings(
|
|
||||||
@CurrentUser() user: { organizationId: string; role: string },
|
|
||||||
@Body() dto: UpdateConversationSettingsDto,
|
|
||||||
) {
|
|
||||||
return this.conversations.updateSettings(user.organizationId, user.role, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('session')
|
@Get('session')
|
||||||
session(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
session(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
|
||||||
return this.conversations.getSession(user.userId, user.organizationId, user.role);
|
return this.conversations.getSession(user.userId, user.organizationId, user.role);
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import type {
|
|||||||
ConversationSettingsDto,
|
ConversationSettingsDto,
|
||||||
PlatformConversationStatsDto,
|
PlatformConversationStatsDto,
|
||||||
UpdateConversationSettingsRequest,
|
UpdateConversationSettingsRequest,
|
||||||
UserRole,
|
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { canManageSettings } from '@mento/shared';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TahesabCryptoService } from '../integrations/tahesab/tahesab-crypto.service';
|
import { TahesabCryptoService } from '../integrations/tahesab/tahesab-crypto.service';
|
||||||
import { ChatExternalClient, type ExternalRoomRow } from './chat-external.client';
|
import { ChatExternalClient, type ExternalRoomRow } from './chat-external.client';
|
||||||
@@ -59,14 +57,17 @@ export class ConversationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(
|
/** ذخیره تنظیمات اتصال چت — فقط از مسیر سوپرادمین */
|
||||||
|
async upsertSettingsForOrganization(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
role: UserRole | string,
|
|
||||||
dto: UpdateConversationSettingsRequest,
|
dto: UpdateConversationSettingsRequest,
|
||||||
): Promise<ConversationSettingsDto> {
|
): Promise<ConversationSettingsDto> {
|
||||||
if (!canManageSettings(role)) {
|
const org = await this.prisma.organization.findUnique({
|
||||||
throw new ForbiddenException('فقط مدیر میتواند تنظیمات گفتگو را تغییر دهد');
|
where: { id: organizationId },
|
||||||
}
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!org) throw new NotFoundException('محیط پیدا نشد');
|
||||||
|
|
||||||
const host = this.chat.normalizeHost(dto.chatHost);
|
const host = this.chat.normalizeHost(dto.chatHost);
|
||||||
if (!host) throw new BadRequestException('آدرس سرویس گفتگو نامعتبر است');
|
if (!host) throw new BadRequestException('آدرس سرویس گفتگو نامعتبر است');
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,66 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type { LeadImportJobDto } from '@mento/shared';
|
import type { LeadImportJobDto } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
parseExcelMapping,
|
||||||
|
previewExcel,
|
||||||
|
readMappedRows,
|
||||||
|
type ExcelFieldOption,
|
||||||
|
type ExcelPreviewResult,
|
||||||
|
} from '../common/excel';
|
||||||
import { LeadStagesService } from './lead-stages.service';
|
import { LeadStagesService } from './lead-stages.service';
|
||||||
import { LeadsService } from './leads.service';
|
import { LeadsService } from './leads.service';
|
||||||
|
|
||||||
|
const LEAD_ALIASES: Record<string, string> = {
|
||||||
|
title: 'title',
|
||||||
|
name: 'title',
|
||||||
|
shop: 'title',
|
||||||
|
نام: 'title',
|
||||||
|
نامفروشگاه: 'title',
|
||||||
|
فروشگاه: 'title',
|
||||||
|
عنوان: 'title',
|
||||||
|
contactname: 'contactName',
|
||||||
|
owner: 'contactName',
|
||||||
|
مالک: 'contactName',
|
||||||
|
مسئول: 'contactName',
|
||||||
|
ناممالک: 'contactName',
|
||||||
|
ناممخاطب: 'contactName',
|
||||||
|
mobile: 'mobile',
|
||||||
|
phone: 'mobile',
|
||||||
|
tel: 'mobile',
|
||||||
|
موبایل: 'mobile',
|
||||||
|
شماره: 'mobile',
|
||||||
|
شمارهتماس: 'mobile',
|
||||||
|
تلفن: 'phone',
|
||||||
|
تلفنثابت: 'phone',
|
||||||
|
city: 'city',
|
||||||
|
شهر: 'city',
|
||||||
|
region: 'region',
|
||||||
|
منطقه: 'region',
|
||||||
|
محله: 'region',
|
||||||
|
trade: 'trade',
|
||||||
|
صنف: 'trade',
|
||||||
|
رسته: 'trade',
|
||||||
|
source: 'source',
|
||||||
|
منبع: 'source',
|
||||||
|
منبعلید: 'source',
|
||||||
|
notes: 'notes',
|
||||||
|
توضیحات: 'notes',
|
||||||
|
یادداشت: 'notes',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_FIELDS: ExcelFieldOption[] = [
|
||||||
|
{ key: 'title', label: 'نام / فروشگاه', required: true },
|
||||||
|
{ key: 'mobile', label: 'موبایل', required: true },
|
||||||
|
{ key: 'contactName', label: 'نام مخاطب / مالک' },
|
||||||
|
{ key: 'phone', label: 'تلفن' },
|
||||||
|
{ key: 'city', label: 'شهر' },
|
||||||
|
{ key: 'region', label: 'منطقه' },
|
||||||
|
{ key: 'trade', label: 'صنف' },
|
||||||
|
{ key: 'source', label: 'منبع' },
|
||||||
|
{ key: 'notes', label: 'توضیحات' },
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeadsImportService {
|
export class LeadsImportService {
|
||||||
private readonly logger = new Logger(LeadsImportService.name);
|
private readonly logger = new Logger(LeadsImportService.name);
|
||||||
@@ -17,6 +74,22 @@ export class LeadsImportService {
|
|||||||
private readonly leads: LeadsService,
|
private readonly leads: LeadsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||||
|
return previewExcel(file, LEAD_ALIASES, LEAD_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rowsFromFile(
|
||||||
|
file: Express.Multer.File,
|
||||||
|
mappingRaw?: string,
|
||||||
|
): Promise<Array<Record<string, string>>> {
|
||||||
|
const rows = await readMappedRows(
|
||||||
|
file,
|
||||||
|
LEAD_ALIASES,
|
||||||
|
parseExcelMapping(mappingRaw),
|
||||||
|
);
|
||||||
|
return rows.map((r) => this.normalizeRow(r));
|
||||||
|
}
|
||||||
|
|
||||||
async enqueue(
|
async enqueue(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
createdById: string,
|
createdById: string,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { cellStr } from '../common/excel';
|
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
AssignLeadsDto,
|
AssignLeadsDto,
|
||||||
@@ -139,36 +138,43 @@ export class LeadsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('import/preview')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@Roles('owner', 'admin')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 15_000_000 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
importPreview(@UploadedFile() file?: Express.Multer.File) {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
|
}
|
||||||
|
return this.imports.preview(file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('import/file')
|
@Post('import/file')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 15_000_000 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
async importFile(
|
async importFile(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@UploadedFile() file?: Express.Multer.File,
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
@Body('mapping') mapping?: string,
|
||||||
) {
|
) {
|
||||||
if (!file?.buffer?.length) {
|
if (!file?.buffer?.length) {
|
||||||
throw new BadRequestException('فایل ارسال نشده');
|
throw new BadRequestException('فایل ارسال نشده');
|
||||||
}
|
}
|
||||||
const wb = new ExcelJS.Workbook();
|
const rows = await this.imports.rowsFromFile(file, mapping);
|
||||||
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
|
if (!rows.length) {
|
||||||
const sheet = wb.worksheets[0];
|
throw new BadRequestException('ردیفی برای ایمپورت نیست');
|
||||||
if (!sheet) throw new BadRequestException('برگه اکسل خالی است');
|
}
|
||||||
const headerRow = sheet.getRow(1);
|
|
||||||
const headers: string[] = [];
|
|
||||||
headerRow.eachCell({ includeEmpty: true }, (cell, col) => {
|
|
||||||
headers[col - 1] = cellStr(cell.value);
|
|
||||||
});
|
|
||||||
const rows: Array<Record<string, string>> = [];
|
|
||||||
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
||||||
if (rowNumber === 1) return;
|
|
||||||
const obj: Record<string, string> = {};
|
|
||||||
headers.forEach((h, i) => {
|
|
||||||
if (!h) return;
|
|
||||||
obj[h] = cellStr(row.getCell(i + 1).value);
|
|
||||||
});
|
|
||||||
if (Object.values(obj).some((v) => v.trim())) rows.push(obj);
|
|
||||||
});
|
|
||||||
return this.imports.enqueue(
|
return this.imports.enqueue(
|
||||||
user.organizationId,
|
user.organizationId,
|
||||||
user.userId,
|
user.userId,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
UpdatePlatformOrganizationDto,
|
UpdatePlatformOrganizationDto,
|
||||||
UpdatePlatformStatusDto,
|
UpdatePlatformStatusDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
import { UpdateConversationSettingsDto } from '../conversations/dto';
|
||||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||||
import type { PlatformActor } from './platform-jwt.strategy';
|
import type { PlatformActor } from './platform-jwt.strategy';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from './platform.service';
|
||||||
@@ -129,6 +130,19 @@ export class PlatformController {
|
|||||||
return this.platform.updateOrganizationModules(id, dto);
|
return this.platform.updateOrganizationModules(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('organizations/:id/conversations/settings')
|
||||||
|
getOrganizationConversationSettings(@Param('id') id: string) {
|
||||||
|
return this.conversations.getSettings(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('organizations/:id/conversations/settings')
|
||||||
|
updateOrganizationConversationSettings(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateConversationSettingsDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.upsertSettingsForOrganization(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('memberships/:id')
|
@Patch('memberships/:id')
|
||||||
setMembershipActive(
|
setMembershipActive(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
start: [];
|
start: [];
|
||||||
done: [ImportContactsResult];
|
done: [ImportContactsResult | Record<string, unknown>];
|
||||||
fail: [string];
|
fail: [string];
|
||||||
settled: [];
|
settled: [];
|
||||||
}>();
|
}>();
|
||||||
@@ -64,9 +64,13 @@ async function postImport(file: File, map?: Record<string, string>) {
|
|||||||
if (map && Object.values(map).some(Boolean)) {
|
if (map && Object.values(map).some(Boolean)) {
|
||||||
form.append('mapping', JSON.stringify(map));
|
form.append('mapping', JSON.stringify(map));
|
||||||
}
|
}
|
||||||
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
|
const { data } = await api.post<ImportContactsResult | Record<string, unknown>>(
|
||||||
headers: { 'Content-Type': undefined },
|
props.importPath,
|
||||||
});
|
form,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': undefined },
|
||||||
|
},
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ const salesActive = computed(() =>
|
|||||||
);
|
);
|
||||||
const activitiesActive = computed(() => pathIn(['/activities']));
|
const activitiesActive = computed(() => pathIn(['/activities']));
|
||||||
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
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(
|
const automationsActive = computed(
|
||||||
() =>
|
() =>
|
||||||
route.path.startsWith('/settings/automations') ||
|
route.path.startsWith('/settings/automations') ||
|
||||||
@@ -91,13 +92,13 @@ const settingsActive = computed(() =>
|
|||||||
'/settings/tahesab',
|
'/settings/tahesab',
|
||||||
'/settings/woocommerce',
|
'/settings/woocommerce',
|
||||||
'/settings/voip',
|
'/settings/voip',
|
||||||
'/settings/conversations',
|
|
||||||
'/settings/faq',
|
'/settings/faq',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const moreSectionActive = computed(
|
const moreSectionActive = computed(
|
||||||
() =>
|
() =>
|
||||||
notesActive.value ||
|
notesActive.value ||
|
||||||
|
conversationsActive.value ||
|
||||||
supportActive.value ||
|
supportActive.value ||
|
||||||
loyaltyActive.value ||
|
loyaltyActive.value ||
|
||||||
reportsActive.value ||
|
reportsActive.value ||
|
||||||
@@ -492,6 +493,18 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<RouterLink
|
||||||
to="/notes"
|
to="/notes"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
@@ -831,6 +844,16 @@ onUnmounted(() => {
|
|||||||
<UiIcon name="document" :size="18" />
|
<UiIcon name="document" :size="18" />
|
||||||
<span>یادداشتها</span>
|
<span>یادداشتها</span>
|
||||||
</RouterLink>
|
</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
|
<button
|
||||||
class="nav-item nav-fold-btn"
|
class="nav-item nav-fold-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -247,12 +247,6 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
|
||||||
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
{ to: '/tickets', title: 'تیکتها', icon: 'chat', module: 'tickets' },
|
||||||
{
|
|
||||||
to: '/conversations',
|
|
||||||
title: 'گفتگو',
|
|
||||||
icon: 'chat',
|
|
||||||
module: 'conversations',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
@@ -263,14 +257,6 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
tint: 'peach',
|
tint: 'peach',
|
||||||
module: 'tickets',
|
module: 'tickets',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: '/conversations',
|
|
||||||
title: 'گفتگوی درونسازمانی',
|
|
||||||
desc: 'پیام خصوصی و گروه بین همکاران',
|
|
||||||
icon: 'chat',
|
|
||||||
tint: 'violet',
|
|
||||||
module: 'conversations',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -484,15 +470,6 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
module: 'voip',
|
module: 'voip',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: '/settings/conversations',
|
|
||||||
title: 'گفتگوی درونسازمانی',
|
|
||||||
desc: 'اتصال به سرویس چت (Host و API Key)',
|
|
||||||
icon: 'chat',
|
|
||||||
tint: 'violet',
|
|
||||||
module: 'conversations',
|
|
||||||
admin: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
to: '/settings/faq',
|
to: '/settings/faq',
|
||||||
title: 'FAQ',
|
title: 'FAQ',
|
||||||
|
|||||||
@@ -349,12 +349,6 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/VoipSettingsView.vue'),
|
component: () => import('@/views/VoipSettingsView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
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',
|
path: 'settings/faq',
|
||||||
name: 'faq-settings',
|
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,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type {
|
import type {
|
||||||
@@ -8,13 +7,11 @@ import type {
|
|||||||
ConversationPeerDto,
|
ConversationPeerDto,
|
||||||
ConversationRoomDto,
|
ConversationRoomDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||||
import { useOrgChat } from '@/lib/useOrgChat';
|
import { useOrgChat } from '@/lib/useOrgChat';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import UserAvatar from '@/components/UserAvatar.vue';
|
import UserAvatar from '@/components/UserAvatar.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const access = ref<ConversationAccessDto | null>(null);
|
const access = ref<ConversationAccessDto | null>(null);
|
||||||
const rooms = ref<ConversationRoomDto[]>([]);
|
const rooms = ref<ConversationRoomDto[]>([]);
|
||||||
const peers = ref<ConversationPeerDto[]>([]);
|
const peers = ref<ConversationPeerDto[]>([]);
|
||||||
@@ -188,11 +185,8 @@ onMounted(() => {
|
|||||||
<h1>گفتگو</h1>
|
<h1>گفتگو</h1>
|
||||||
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
UiPageHeader,
|
UiPageHeader,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||||
import {
|
import {
|
||||||
filterPhoneContacts,
|
filterPhoneContacts,
|
||||||
@@ -120,7 +121,6 @@ const assignOpen = ref(false);
|
|||||||
const stageOpen = ref(false);
|
const stageOpen = ref(false);
|
||||||
const assignUserId = ref('');
|
const assignUserId = ref('');
|
||||||
const bulkStageId = ref('');
|
const bulkStageId = ref('');
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
const importJob = ref<LeadImportJobDto | null>(null);
|
const importJob = ref<LeadImportJobDto | null>(null);
|
||||||
const createOpen = ref(false);
|
const createOpen = ref(false);
|
||||||
const createBusy = ref(false);
|
const createBusy = ref(false);
|
||||||
@@ -935,27 +935,28 @@ function startImportPoll(jobId: string) {
|
|||||||
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onImportFile(ev: Event) {
|
function onExcelImportStart() {
|
||||||
const input = ev.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
input.value = '';
|
|
||||||
if (!file) return;
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = '';
|
|
||||||
okMsg.value = '';
|
okMsg.value = '';
|
||||||
try {
|
error.value = '';
|
||||||
const form = new FormData();
|
}
|
||||||
form.append('file', file);
|
|
||||||
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
|
function onExcelImportDone(result: Record<string, unknown> | LeadImportJobDto) {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
const job = result as LeadImportJobDto;
|
||||||
});
|
if (!job?.id) {
|
||||||
importJob.value = job;
|
|
||||||
startImportPoll(job.id);
|
|
||||||
} catch {
|
|
||||||
error.value = 'ورود فایل ناموفق بود.';
|
error.value = 'ورود فایل ناموفق بود.';
|
||||||
} finally {
|
return;
|
||||||
busy.value = false;
|
|
||||||
}
|
}
|
||||||
|
importJob.value = job;
|
||||||
|
startImportPoll(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExcelImportFail(message: string) {
|
||||||
|
error.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExcelImportSettled() {
|
||||||
|
busy.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPhoneImportStart() {
|
function onPhoneImportStart() {
|
||||||
@@ -1034,22 +1035,20 @@ onBeforeUnmount(() => {
|
|||||||
<span class="btn-full">خروجی اکسل</span>
|
<span class="btn-full">خروجی اکسل</span>
|
||||||
<span class="btn-short">خروجی</span>
|
<span class="btn-short">خروجی</span>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton
|
<ExcelImportFlow
|
||||||
variant="ghost"
|
preview-path="/leads/import/preview"
|
||||||
size="sm"
|
import-path="/leads/import/file"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click.stop="fileInput?.click()"
|
@start="onExcelImportStart"
|
||||||
|
@done="onExcelImportDone"
|
||||||
|
@fail="onExcelImportFail"
|
||||||
|
@settled="onExcelImportSettled"
|
||||||
>
|
>
|
||||||
<span class="btn-full">ورود از اکسل</span>
|
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||||||
<span class="btn-short">ورود</span>
|
<span class="btn-full">ورود از اکسل</span>
|
||||||
</UiButton>
|
<span class="btn-short">ورود</span>
|
||||||
<input
|
</UiButton>
|
||||||
ref="fileInput"
|
</ExcelImportFlow>
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls,.csv"
|
|
||||||
hidden
|
|
||||||
@change="onImportFile"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref="logoFileInput"
|
ref="logoFileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1565,9 +1564,18 @@ onBeforeUnmount(() => {
|
|||||||
<template #action>
|
<template #action>
|
||||||
<div class="empty-actions">
|
<div class="empty-actions">
|
||||||
<UiButton variant="primary" @click="openCreate">ثبت لید جدید</UiButton>
|
<UiButton variant="primary" @click="openCreate">ثبت لید جدید</UiButton>
|
||||||
<UiButton v-if="isAdmin" variant="ghost" :disabled="busy" @click="fileInput?.click()">
|
<ExcelImportFlow
|
||||||
ورود از فایل
|
v-if="isAdmin"
|
||||||
</UiButton>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UiEmptyState>
|
</UiEmptyState>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import axios from 'axios';
|
|||||||
import type {
|
import type {
|
||||||
ApplyBillingPlanRequest,
|
ApplyBillingPlanRequest,
|
||||||
BillingPlanCode,
|
BillingPlanCode,
|
||||||
|
ConversationSettingsDto,
|
||||||
CreatePlatformOrganizationRequest,
|
CreatePlatformOrganizationRequest,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
PlatformUserDto,
|
PlatformUserDto,
|
||||||
|
UpdateConversationSettingsRequest,
|
||||||
UpdatePlatformOrganizationRequest,
|
UpdatePlatformOrganizationRequest,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -55,6 +57,16 @@ const editForm = reactive({
|
|||||||
const moduleDraft = ref<OrgModuleKey[]>([]);
|
const moduleDraft = ref<OrgModuleKey[]>([]);
|
||||||
const savingModules = ref(false);
|
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) {
|
function fieldLabel(value?: string | null) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
|
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
|
||||||
@@ -98,6 +110,7 @@ async function toggleOpen(org: PlatformOrganizationDto) {
|
|||||||
openId.value = null;
|
openId.value = null;
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
|
chatLoadedFor.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openId.value = org.id;
|
openId.value = org.id;
|
||||||
@@ -108,11 +121,56 @@ async function toggleOpen(org: PlatformOrganizationDto) {
|
|||||||
);
|
);
|
||||||
detail.value = data;
|
detail.value = data;
|
||||||
moduleDraft.value = [...(data.enabledModules ?? [])];
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
|
await loadChatSettings(org.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(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) {
|
function startEdit(org: PlatformOrganizationDto) {
|
||||||
editingId.value = org.id;
|
editingId.value = org.id;
|
||||||
editForm.name = org.name;
|
editForm.name = org.name;
|
||||||
@@ -469,6 +527,51 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="member in detail.members" :key="member.membershipId">
|
<li v-for="member in detail.members" :key="member.membershipId">
|
||||||
@@ -716,6 +819,33 @@ label span {
|
|||||||
border: 1px solid var(--line);
|
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,
|
.modules-head,
|
||||||
.mod-group-head {
|
.mod-group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
|||||||
woocommerce: "ووکامرس",
|
woocommerce: "ووکامرس",
|
||||||
voip: "تلفن سازمانی",
|
voip: "تلفن سازمانی",
|
||||||
reservations: "رزرو و نوبتدهی",
|
reservations: "رزرو و نوبتدهی",
|
||||||
conversations: "گفتگوی درونسازمانی",
|
conversations: "گفتگو",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ORG_MODULE_GROUPS: Array<{
|
export const ORG_MODULE_GROUPS: Array<{
|
||||||
@@ -358,7 +358,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
|||||||
{ key: "notes", label: "یادداشتها" },
|
{ key: "notes", label: "یادداشتها" },
|
||||||
{ key: "reports", label: "گزارشها" },
|
{ key: "reports", label: "گزارشها" },
|
||||||
{ key: "audit", label: "لاگ فعالیت کاربران" },
|
{ key: "audit", label: "لاگ فعالیت کاربران" },
|
||||||
{ key: "conversations", label: "گفتگوی درونسازمانی" },
|
{ key: "conversations", label: "گفتگو" },
|
||||||
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
|
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user