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

653 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, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ConversationAccessDto,
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[]>([]);
const loading = ref(true);
const error = ref('');
const activeRoom = ref<ConversationRoomDto | null>(null);
const draft = ref('');
const showNewDm = ref(false);
const showNewGroup = ref(false);
const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
const sortedRooms = computed(() =>
[...rooms.value].sort((a, b) => {
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
return tb - ta;
}),
);
const chatUserId = ref('');
function roomTitle(room: ConversationRoomDto) {
if (room.kind === 'group') return room.name;
const peer = peers.value.find((p) => p.id === room.peerUserId);
return peer?.fullName ?? room.name;
}
function formatListTime(iso: string | null) {
if (!iso) return '';
try {
return formatClockFa(new Date(iso));
} catch {
return '';
}
}
async function bootstrap() {
loading.value = true;
error.value = '';
try {
const [accessRes, roomsRes, peersRes] = await Promise.all([
api.get<ConversationAccessDto>('/conversations/access'),
api.get<ConversationRoomDto[]>('/conversations/rooms'),
api.get<ConversationPeerDto[]>('/conversations/peers'),
]);
access.value = accessRes.data;
if (!accessRes.data.canUse) {
error.value = 'به گفتگوی درون‌سازمانی دسترسی ندارید.';
return;
}
rooms.value = roomsRes.data;
peers.value = peersRes.data;
const sessionRes = await api.get<{ chatUserId: string }>('/conversations/session');
chatUserId.value = sessionRes.data.chatUserId;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
} else if (axios.isAxiosError(e) && e.response?.status === 400) {
error.value =
(e.response.data as { message?: string })?.message ??
'ابتدا تنظیمات گفتگو را تکمیل کنید.';
} else {
error.value = 'بارگذاری گفتگو ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function openRoom(room: ConversationRoomDto) {
activeRoom.value = room;
await loadMessages(room.externalRoomId);
sendRead(room.externalRoomId);
}
async function submitMessage() {
if (!activeRoom.value || !draft.value.trim()) return;
try {
await sendText(activeRoom.value.externalRoomId, draft.value);
draft.value = '';
} catch {
error.value = 'ارسال پیام ناموفق بود.';
}
}
async function startDm() {
if (!dmPeerId.value) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
peerUserId: dmPeerId.value,
});
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
showNewDm.value = false;
dmPeerId.value = '';
await openRoom(data);
} catch {
error.value = 'شروع گفتگوی جدید ناموفق بود.';
}
}
async function startGroup() {
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
name: groupName.value.trim(),
memberUserIds: groupMemberIds.value,
});
rooms.value = [data, ...rooms.value];
showNewGroup.value = false;
groupName.value = '';
groupMemberIds.value = [];
await openRoom(data);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'اجازه ساخت گروه را ندارید.';
} else {
error.value = 'ساخت گروه ناموفق بود.';
}
}
}
function toggleGroupMember(id: string) {
if (groupMemberIds.value.includes(id)) {
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
} else {
groupMemberIds.value = [...groupMemberIds.value, id];
}
}
watch(activeRoom, () => {
draft.value = '';
});
onMounted(() => {
void bootstrap();
});
</script>
<template>
<div class="conv-page">
<div class="conv-aurora" aria-hidden="true" />
<header class="conv-head">
<div>
<p class="eyebrow">درون سازمان</p>
<h1>گفتگو</h1>
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیام‌رسان بیرونی.</p>
</div>
<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>
<p v-if="error" class="err-banner">{{ error }}</p>
<p v-else-if="loading" class="muted center">در حال آماده‌سازی…</p>
<div v-else class="conv-shell">
<aside class="conv-rail">
<div class="rail-actions">
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
<button
v-if="canCreateGroup"
type="button"
class="fab ghost"
@click="showNewGroup = true"
>
گروه
</button>
</div>
<ul class="room-list">
<li v-for="room in sortedRooms" :key="room.externalRoomId">
<button
type="button"
class="room-chip"
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
@click="openRoom(room)"
>
<span class="avatar-ring">
<UserAvatar :name="roomTitle(room)" :size="40" />
</span>
<span class="room-meta">
<strong>{{ roomTitle(room) }}</strong>
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
</span>
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
<time>{{ formatListTime(room.lastMessageAt) }}</time>
</button>
</li>
</ul>
</aside>
<section class="conv-stage">
<div v-if="!activeRoom" class="empty-stage">
<div class="empty-glow" />
<UiIcon name="chat" :size="48" />
<h2>یک مکالمه را انتخاب کنید</h2>
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
</div>
<template v-else>
<div class="stage-top">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
<ul v-else class="msg-stream">
<li
v-for="msg in messages"
:key="msg.id"
class="msg-row"
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
>
<div class="bubble">
<p>{{ msg.content }}</p>
<time>{{ formatListTime(msg.createdAt) }}</time>
</div>
</li>
</ul>
<form class="composer" @submit.prevent="submitMessage">
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
<button type="submit" :disabled="!draft.trim()">
<UiIcon name="send" :size="18" />
</button>
</form>
</template>
</section>
</div>
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
<div class="modal-card">
<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>
<div class="modal-actions">
<button type="button" @click="showNewDm = false">انصراف</button>
<button type="button" class="primary" @click="startDm">شروع</button>
</div>
</div>
</dialog>
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
<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>
<div class="modal-actions">
<button type="button" @click="showNewGroup = false">انصراف</button>
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
</div>
</div>
</dialog>
</div>
</template>
<style scoped>
.conv-page {
position: relative;
min-height: calc(100vh - 4rem);
padding: 1rem 1.25rem 1.5rem;
overflow: hidden;
}
.conv-aurora {
position: absolute;
inset: -20% -10% auto;
height: 320px;
background:
radial-gradient(ellipse 60% 80% at 85% 0%, rgba(244, 114, 182, 0.22), transparent 60%),
radial-gradient(ellipse 50% 70% at 10% 10%, rgba(45, 212, 191, 0.2), transparent 55%);
pointer-events: none;
z-index: 0;
}
.conv-head {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 0.25rem;
}
.conv-head h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 800;
background: linear-gradient(120deg, #0f766e, #7c3aed 55%, #db2777);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sub {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.head-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ws-pill {
font-size: 0.75rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.ws-pill[data-state='open'] {
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
}
.gear {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--line);
color: var(--text);
}
.err-banner {
background: #fef2f2;
color: #b91c1c;
padding: 0.65rem 0.85rem;
border-radius: 12px;
margin-bottom: 0.75rem;
}
.conv-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 0.75rem;
min-height: 520px;
}
@media (max-width: 900px) {
.conv-shell {
grid-template-columns: 1fr;
}
}
.conv-rail {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 20px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rail-actions {
display: flex;
gap: 0.4rem;
}
.fab {
flex: 1;
border: none;
border-radius: 14px;
padding: 0.55rem 0.65rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(135deg, #0d9488, #6366f1);
color: #fff;
}
.fab.ghost {
background: transparent;
border: 1px dashed rgba(99, 102, 241, 0.45);
color: #4338ca;
}
.room-list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
max-height: calc(100vh - 14rem);
}
.room-chip {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: 0.15rem 0.65rem;
align-items: center;
text-align: right;
border: none;
background: transparent;
padding: 0.55rem 0.45rem;
border-radius: 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.room-chip:hover,
.room-chip.active {
background: rgba(99, 102, 241, 0.08);
}
.avatar-ring {
grid-row: span 2;
border-radius: 999px;
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.room-meta strong {
display: block;
font-size: 0.92rem;
}
.room-meta small {
color: var(--muted);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
display: block;
}
.unread {
grid-column: 3;
grid-row: 1;
background: #db2777;
color: #fff;
font-size: 0.7rem;
min-width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
display: grid;
place-items: center;
}
.room-chip time {
grid-column: 3;
grid-row: 2;
font-size: 0.68rem;
color: var(--muted);
}
.conv-stage {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(14px);
border-radius: 22px;
border: 1px solid rgba(148, 163, 184, 0.22);
display: flex;
flex-direction: column;
min-height: 480px;
overflow: hidden;
}
.stage-top {
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 0.5rem;
}
.stage-top h2 {
margin: 0;
font-size: 1.1rem;
}
.kind-tag {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
}
.msg-stream {
flex: 1;
list-style: none;
margin: 0;
padding: 1rem 1.15rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.msg-row {
display: flex;
justify-content: flex-start;
}
.msg-row.mine {
justify-content: flex-end;
}
.bubble {
max-width: min(420px, 85%);
padding: 0.65rem 0.85rem;
border-radius: 18px 18px 18px 4px;
background: #f1f5f9;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.msg-row.mine .bubble {
border-radius: 18px 18px 4px 18px;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
}
.bubble p {
margin: 0 0 0.25rem;
white-space: pre-wrap;
word-break: break-word;
}
.bubble time {
font-size: 0.68rem;
color: var(--muted);
}
.composer {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem;
border-top: 1px solid var(--line);
}
.composer input {
flex: 1;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.65rem 0.85rem;
font: inherit;
}
.composer button {
border: none;
border-radius: 14px;
width: 2.75rem;
background: #0d9488;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.empty-stage {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.35rem;
color: var(--muted);
position: relative;
}
.empty-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
filter: blur(8px);
}
.modal {
position: fixed;
inset: 0;
z-index: 40;
border: none;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 1rem;
}
.modal-card {
background: var(--surface);
border-radius: 18px;
padding: 1.25rem;
width: min(400px, 100%);
display: grid;
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;
overflow: auto;
}
.member-check {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-actions .primary {
background: #6366f1;
color: #fff;
border: none;
padding: 0.45rem 0.85rem;
border-radius: 10px;
cursor: pointer;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
.pad {
padding: 2rem;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
</style>