Added message-to-activity conversion and edited the colleague selection modal.

This commit is contained in:
2026-09-30 11:24:58 +03:30
parent 4c8731122b
commit b978708487
+556 -54
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ActivityDto,
ActivityType,
ConversationAccessDto,
ConversationPeerDto,
ConversationPinnedMessageDto,
ConversationRoomDto,
CreateActivityRequest,
} from '@mento/shared';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
import {
@@ -20,6 +24,7 @@ import {
type MessagePinEvent,
type RoomLiveEvent,
} from '@/lib/useOrgChat';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
@@ -31,6 +36,8 @@ type PendingAttach = {
name: string;
};
const router = useRouter();
const auth = useAuthStore();
const access = ref<ConversationAccessDto | null>(null);
const rooms = ref<ConversationRoomDto[]>([]);
const peers = ref<ConversationPeerDto[]>([]);
@@ -46,6 +53,7 @@ const groupMemberIds = ref<string[]>([]);
const channelName = ref('');
const channelMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const peerPickerQuery = ref('');
const chatUserId = ref('');
const pendingAttaches = ref<PendingAttach[]>([]);
const sending = ref(false);
@@ -106,6 +114,20 @@ const chatSearchQuery = ref('');
const chatSearchIndex = ref(0);
const mentionState = ref<{ start: number; end: number; query: string } | null>(null);
const mentionIndex = ref(0);
const showActivityFromMsg = ref(false);
const activityFromMsg = ref<ChatMessage | null>(null);
const activityTitle = ref('');
const activityType = ref<ActivityType>('task');
const activityBusy = ref(false);
const activityError = ref('');
const activityCreatedId = ref('');
const ACTIVITY_TYPE_OPTS: Array<{ value: ActivityType; label: string }> = [
{ value: 'task', label: 'وظیفه' },
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'note', label: 'یادداشت' },
];
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
const canCreateChannel = computed(
@@ -118,6 +140,19 @@ const canPostInActive = computed(() => {
return room.canPost !== false;
});
const filteredPeers = computed(() => {
const q = peerPickerQuery.value.trim().toLowerCase();
if (!q) return peers.value;
return peers.value.filter((p) => p.fullName.toLowerCase().includes(q));
});
const selectedGroupPeers = computed(() =>
peers.value.filter((p) => groupMemberIds.value.includes(p.id)),
);
const selectedChannelPeers = computed(() =>
peers.value.filter((p) => channelMemberIds.value.includes(p.id)),
);
async function scrollToBottom() {
await nextTick();
const el = msgStreamEl.value;
@@ -275,6 +310,7 @@ function closeMobileChat() {
closeChatSearch();
mentionState.value = null;
pinnedMessage.value = null;
closeActivityFromMsg();
if (recording.value) mediaRecorder?.stop();
}
@@ -1094,6 +1130,112 @@ function toggleChannelMember(id: string) {
}
}
function openNewDm() {
peerPickerQuery.value = '';
dmPeerId.value = '';
showNewDm.value = true;
}
function openNewGroup() {
peerPickerQuery.value = '';
groupName.value = '';
groupMemberIds.value = [];
showNewGroup.value = true;
}
function openNewChannel() {
peerPickerQuery.value = '';
channelName.value = '';
channelMemberIds.value = [];
showNewChannel.value = true;
}
function messageToActivityTitle(msg: ChatMessage) {
if (msg.type === 'text') {
const t = (msg.content || '').trim().replace(/\s+/g, ' ');
if (!t) return 'پیگیری گفتگو';
return t.length > 80 ? `${t.slice(0, 80)}…` : t;
}
return `پیگیری: ${previewLabelForMessage(msg)}`;
}
function messageToActivityDescription(msg: ChatMessage) {
const roomName = activeRoom.value ? roomTitle(activeRoom.value) : 'گفتگو';
const body =
msg.type === 'text'
? (msg.content || '').trim()
: previewLabelForMessage(msg);
return `ایجادشده از گفتگو «${roomName}»\n\n${body}`.trim();
}
function openConvertToActivity(msg: ChatMessage) {
if (msg.deleted || msg.optimistic) return;
msgMenu.value = null;
activityFromMsg.value = msg;
activityTitle.value = messageToActivityTitle(msg);
activityType.value = 'task';
activityError.value = '';
activityCreatedId.value = '';
showActivityFromMsg.value = true;
}
function closeActivityFromMsg() {
showActivityFromMsg.value = false;
activityFromMsg.value = null;
activityBusy.value = false;
activityError.value = '';
activityCreatedId.value = '';
}
async function submitActivityFromMsg() {
const msg = activityFromMsg.value;
const title = activityTitle.value.trim();
if (!msg || !title) {
activityError.value = 'عنوان فعالیت را وارد کنید.';
return;
}
activityBusy.value = true;
activityError.value = '';
try {
const payload: CreateActivityRequest = {
title,
type: activityType.value,
description: messageToActivityDescription(msg),
assigneeId: auth.user?.id || undefined,
tags: ['گفتگو'],
};
const { data } = await api.post<ActivityDto>('/activities', payload);
activityCreatedId.value = data.id;
} catch (e) {
if (axios.isAxiosError(e)) {
const msgBody = (e.response?.data as { message?: string | string[] })?.message;
activityError.value = Array.isArray(msgBody)
? msgBody.join('، ')
: msgBody || 'ساخت فعالیت ناموفق بود.';
} else {
activityError.value = 'ساخت فعالیت ناموفق بود.';
}
} finally {
activityBusy.value = false;
}
}
async function openCreatedActivity() {
const id = activityCreatedId.value;
closeActivityFromMsg();
if (id) await router.push({ name: 'activity-detail', params: { id } });
}
watch(showNewDm, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(showNewGroup, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(showNewChannel, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(activeRoom, () => {
draft.value = '';
clearPendingAttaches();
@@ -1177,12 +1319,12 @@ onUnmounted(() => {
<div v-else class="conv-shell">
<aside class="conv-rail" :hidden="mobileChatOpen">
<div class="rail-actions">
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
<button type="button" class="fab" @click="openNewDm">+ گفتگو</button>
<button
v-if="canCreateGroup"
type="button"
class="fab ghost"
@click="showNewGroup = true"
@click="openNewGroup"
>
گروه
</button>
@@ -1190,7 +1332,7 @@ onUnmounted(() => {
v-if="canCreateChannel"
type="button"
class="fab ghost"
@click="showNewChannel = true"
@click="openNewChannel"
>
کانال
</button>
@@ -1706,6 +1848,10 @@ onUnmounted(() => {
<UiIcon name="send" :size="16" />
ارسال برای دیگری
</button>
<button type="button" @click="openConvertToActivity(menuMessage()!)">
<UiIcon name="check" :size="16" />
تبدیل به فعالیت
</button>
<button
v-if="isMineMessage(menuMessage()) && menuMessage()?.type === 'text'"
type="button"
@@ -1739,15 +1885,41 @@ onUnmounted(() => {
</div>
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
<div class="modal-card">
<div class="modal-card wide">
<h3>گفتگوی جدید</h3>
<select v-model="dmPeerId">
<option value="">همکار را انتخاب کنید</option>
<option v-for="p in peers" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
<p class="hint">همکار موردنظر را انتخاب کنید</p>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی نام…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: dmPeerId === p.id }"
@click="dmPeerId = p.id"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>گفتگوی خصوصی</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="dmPeerId === p.id" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showNewDm = false">انصراف</button>
<button type="button" class="primary" @click="startDm">شروع</button>
<button type="button" class="primary" :disabled="!dmPeerId" @click="startDm">شروع</button>
</div>
</div>
</dialog>
@@ -1756,20 +1928,65 @@ onUnmounted(() => {
<div class="modal-card wide">
<h3>گروه جدید</h3>
<input v-model="groupName" type="text" placeholder="نام گروه" />
<p class="hint">اعضا</p>
<div class="member-grid">
<label v-for="p in peers" :key="p.id" class="member-check">
<input
type="checkbox"
:checked="groupMemberIds.includes(p.id)"
@change="toggleGroupMember(p.id)"
/>
{{ p.fullName }}
</label>
<div class="picker-head">
<p class="hint">اعضا را انتخاب کنید</p>
<span v-if="groupMemberIds.length" class="picker-count">
{{ toFaDigits(groupMemberIds.length) }} نفر
</span>
</div>
<div v-if="selectedGroupPeers.length" class="picked-chips">
<button
v-for="p in selectedGroupPeers"
:key="p.id"
type="button"
class="picked-chip"
:title="`حذف ${p.fullName}`"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی همکار…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: groupMemberIds.includes(p.id) }"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ groupMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="groupMemberIds.includes(p.id)" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showNewGroup = false">انصراف</button>
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
<button
type="button"
class="primary"
:disabled="!groupName.trim() || !groupMemberIds.length"
@click="startGroup"
>
ساخت گروه
</button>
</div>
</div>
</dialog>
@@ -1777,26 +1994,124 @@ onUnmounted(() => {
<dialog v-if="showNewChannel" class="modal" open @click.self="showNewChannel = false">
<div class="modal-card wide">
<h3>کانال جدید</h3>
<p class="hint">کانال یک‌طرفه است؛ فقط شما و مدیران سازمان می‌توانند پیام بفرستند.</p>
<p class="hint channel-hint">
کانال یک‌طرفه است؛ فقط شما و مدیران سازمان می‌توانند پیام بفرستند.
</p>
<input v-model="channelName" type="text" placeholder="نام کانال" />
<p class="hint">اعضا (مشاهده‌کنندگان)</p>
<div class="member-grid">
<label v-for="p in peers" :key="p.id" class="member-check">
<input
type="checkbox"
:checked="channelMemberIds.includes(p.id)"
@change="toggleChannelMember(p.id)"
/>
{{ p.fullName }}
</label>
<div class="picker-head">
<p class="hint">مشاهده‌کنندگان</p>
<span v-if="channelMemberIds.length" class="picker-count">
{{ toFaDigits(channelMemberIds.length) }} نفر
</span>
</div>
<div v-if="selectedChannelPeers.length" class="picked-chips">
<button
v-for="p in selectedChannelPeers"
:key="p.id"
type="button"
class="picked-chip"
:title="`حذف ${p.fullName}`"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی همکار…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: channelMemberIds.includes(p.id) }"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ channelMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="channelMemberIds.includes(p.id)" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showNewChannel = false">انصراف</button>
<button type="button" class="primary" @click="startChannel">ساخت کانال</button>
<button
type="button"
class="primary"
:disabled="!channelName.trim() || !channelMemberIds.length"
@click="startChannel"
>
ساخت کانال
</button>
</div>
</div>
</dialog>
<dialog
v-if="showActivityFromMsg"
class="modal"
open
@click.self="closeActivityFromMsg"
>
<div class="modal-card activity-sheet">
<template v-if="!activityCreatedId">
<h3>تبدیل به فعالیت</h3>
<p class="hint">از این پیام یک فعالیت در CRM ساخته می‌شود.</p>
<label class="field-label">عنوان</label>
<input v-model="activityTitle" type="text" placeholder="عنوان فعالیت" />
<label class="field-label">نوع</label>
<div class="type-chips">
<button
v-for="opt in ACTIVITY_TYPE_OPTS"
:key="opt.value"
type="button"
class="type-chip"
:class="{ on: activityType === opt.value }"
@click="activityType = opt.value"
>
{{ opt.label }}
</button>
</div>
<p v-if="activityError" class="act-err">{{ activityError }}</p>
<div class="modal-actions">
<button type="button" :disabled="activityBusy" @click="closeActivityFromMsg">
انصراف
</button>
<button
type="button"
class="primary"
:disabled="activityBusy || !activityTitle.trim()"
@click="submitActivityFromMsg"
>
{{ activityBusy ? '…' : 'ساخت فعالیت' }}
</button>
</div>
</template>
<template v-else>
<h3>فعالیت ساخته شد</h3>
<p class="hint">می‌توانید جزئیات را در صفحه فعالیت کامل کنید.</p>
<div class="modal-actions">
<button type="button" @click="closeActivityFromMsg">بستن</button>
<button type="button" class="primary" @click="openCreatedActivity">مشاهده فعالیت</button>
</div>
</template>
</div>
</dialog>
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
<div class="modal-card">
<h3>ارسال برای دیگری</h3>
@@ -2926,40 +3241,219 @@ onUnmounted(() => {
gap: 0.75rem;
}
.modal-card.wide {
width: min(480px, 100%);
}
.modal-card select,
.modal-card input {
width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
font: inherit;
}
.member-grid {
display: grid;
gap: 0.35rem;
max-height: 200px;
width: min(440px, 100%);
max-height: min(88vh, 640px);
overflow: auto;
}
.member-check {
.modal-card select,
.modal-card input[type='text'],
.modal-card input[type='search'],
.modal-card input:not([type]) {
width: 100%;
padding: 0.6rem 0.75rem;
border-radius: 12px;
border: 1px solid var(--line);
font: inherit;
box-sizing: border-box;
}
.picker-head {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
justify-content: space-between;
gap: 0.5rem;
}
.picker-head .hint {
margin: 0;
}
.picker-count {
font-size: 0.78rem;
font-weight: 700;
color: #0f766e;
background: rgba(13, 148, 136, 0.12);
padding: 0.2rem 0.55rem;
border-radius: 999px;
}
.channel-hint {
margin: 0;
padding: 0.55rem 0.7rem;
border-radius: 12px;
background: #fff7ed;
color: #9a3412;
font-size: 0.82rem;
line-height: 1.45;
}
.peer-search {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border-radius: 12px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.22);
color: var(--muted);
}
.peer-search input {
flex: 1;
min-width: 0;
border: none !important;
background: transparent !important;
padding: 0.15rem 0 !important;
outline: none;
font: inherit;
color: var(--text);
}
.picked-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.picked-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid rgba(13, 148, 136, 0.28);
background: rgba(13, 148, 136, 0.08);
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.45rem 0.2rem 0.25rem;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
max-width: 100%;
}
.picked-chip span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.peer-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 240px;
overflow: auto;
padding: 0.15rem;
margin: 0 -0.15rem;
}
.peer-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
width: 100%;
border: 1px solid transparent;
background: #fff;
border-radius: 14px;
padding: 0.45rem 0.55rem;
text-align: right;
cursor: pointer;
font: inherit;
color: var(--text);
transition: background 0.12s ease, border-color 0.12s ease;
}
.peer-row:hover {
background: rgba(13, 148, 136, 0.05);
}
.peer-row.on {
background: rgba(13, 148, 136, 0.1);
border-color: rgba(13, 148, 136, 0.35);
}
.peer-row-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.peer-row-meta strong {
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.peer-row-meta small {
color: var(--muted);
font-size: 0.74rem;
}
.peer-tick {
width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
border: 1.5px solid rgba(148, 163, 184, 0.55);
display: grid;
place-items: center;
color: #fff;
background: transparent;
flex-shrink: 0;
}
.peer-row.on .peer-tick {
background: #0d9488;
border-color: #0d9488;
}
.peer-empty {
margin: 0.75rem 0;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.type-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.type-chip {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.type-chip.on {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.45);
color: #4338ca;
}
.act-err {
margin: 0;
color: #b91c1c;
font-size: 0.85rem;
}
.activity-sheet {
width: min(400px, 100%);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
.modal-actions .primary {
background: #6366f1;
color: #fff;
border: none;
padding: 0.45rem 0.85rem;
.modal-actions button {
border: 1px solid var(--line);
background: #fff;
padding: 0.5rem 0.9rem;
border-radius: 10px;
cursor: pointer;
font: inherit;
}
.modal-actions .primary {
background: #0d9488;
color: #fff;
border-color: #0d9488;
}
.modal-actions .primary:disabled,
.modal-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.muted {
color: var(--muted);
@@ -3102,6 +3596,14 @@ onUnmounted(() => {
.composer {
position: relative;
}
.activity-sheet,
.modal-card.wide {
width: min(100%, 100vw - 1.5rem);
border-radius: 18px 18px 14px 14px;
}
.peer-list {
max-height: 38vh;
}
.msg-stream {
padding: 0.75rem 0.7rem 0.5rem;
}