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

688 lines
18 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-09-29 11:56:32 +03:30
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ConversationAccessDto,
ConversationPeerDto,
ConversationRoomDto,
} from '@mento/shared';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
import { useOrgChat } from '@/lib/useOrgChat';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
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();
2026-09-29 11:56:32 +03:30
const msgStreamEl = ref<HTMLElement | null>(null);
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
2026-09-29 11:56:32 +03:30
async function scrollToBottom() {
await nextTick();
const el = msgStreamEl.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
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 {
2026-09-29 15:27:14 +03:30
const accessRes = await api.get<ConversationAccessDto>('/conversations/access');
access.value = accessRes.data;
if (!accessRes.data.canUse) {
2026-09-29 15:27:14 +03:30
error.value = 'به گفتگو دسترسی ندارید.';
return;
}
2026-09-29 15:27:14 +03:30
const [roomsOutcome, peersOutcome, sessionOutcome] = await Promise.allSettled([
api.get<ConversationRoomDto[]>('/conversations/rooms'),
api.get<ConversationPeerDto[]>('/conversations/peers'),
api.get<{ chatUserId: string }>('/conversations/session'),
]);
if (peersOutcome.status === 'fulfilled') {
peers.value = peersOutcome.value.data;
}
if (sessionOutcome.status === 'fulfilled') {
chatUserId.value = sessionOutcome.value.data.chatUserId;
}
if (roomsOutcome.status === 'fulfilled') {
rooms.value = roomsOutcome.value.data;
} else {
const e = roomsOutcome.reason;
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: msg ||
(e.response?.status === 503 || e.response?.status === 502
? 'اتصال به سرویس چت برقرار نشد. Host و API Key را در سوپرادمین بررسی کنید.'
: 'بارگذاری لیست گفتگو ناموفق بود.');
} else {
error.value = 'بارگذاری لیست گفتگو ناموفق بود.';
}
}
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
2026-09-29 15:27:14 +03:30
} else if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: msg || 'بارگذاری گفتگو ناموفق بود.';
} 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 = '';
});
2026-09-29 11:56:32 +03:30
watch(
() => messages.value.length,
() => {
void scrollToBottom();
},
);
watch(loadingMessages, (v) => {
if (!v) void scrollToBottom();
});
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>
</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>
2026-09-29 11:56:32 +03:30
<ul v-else ref="msgStreamEl" 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>