Files
CRM_Montzar/apps/web/src/components/activities/ActivityCommentSheet.vue
T

608 lines
13 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
} from '@/lib/jalali';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
}>();
const emit = defineEmits<{
close: [];
submit: [text: string];
}>();
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const comment = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
const mentionOpen = ref(false);
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
const voiceError = ref('');
const recording = ref(false);
const voiceBusy = ref(false);
const subtitle = computed(() => {
const a = props.activity;
return a.deal?.title || a.company?.name || a.person?.fullName || '';
});
const dueLabel = computed(() => {
const due = props.activity.dueAt;
if (!due) return 'بدون موعد';
const d = new Date(due);
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز ${clock}`;
if (days === 1) return `دیروز ${clock}`;
if (days === -1) return `فردا ${clock}`;
return `${formatJalaliDay(d)} ${clock}`;
});
const canSubmit = computed(
() => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value,
);
function onVoiceTranscript(text: string) {
comment.value = appendSpokenText(comment.value, text);
}
function onVoiceBusy(active: boolean) {
voiceBusy.value = active;
if (active && voiceTip.value) {
voiceTip.value = false;
localStorage.setItem('mento.voiceTipSeen', '1');
}
}
function submit() {
const text = comment.value.trim();
if (!text || props.saving || voiceBusy.value) return;
emit('submit', text);
}
function close() {
emit('close');
}
function insertMention(name: string) {
const token = `@${name} `;
const el = textareaRef.value;
if (!el) {
comment.value += token;
mentionOpen.value = false;
return;
}
const start = el.selectionStart ?? comment.value.length;
const end = el.selectionEnd ?? start;
comment.value = `${comment.value.slice(0, start)}${token}${comment.value.slice(end)}`;
mentionOpen.value = false;
void nextTick(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
if (mentionOpen.value) {
mentionOpen.value = false;
return;
}
if (recording.value) return;
close();
}
}
onMounted(async () => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
await nextTick();
textareaRef.value?.focus();
try {
const [{ data: org }, usersRes] = await Promise.all([
api.get<OrganizationDto>('/organizations/current'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
if (org.name) orgName.value = org.name;
users.value = usersRes.data;
if (!users.value.length && props.activity.assignee) {
users.value = [
{
id: props.activity.assignee.id,
email: '',
fullName: props.activity.assignee.fullName,
role: 'sales',
createdAt: '',
},
];
}
} catch {
orgName.value = 'سازمان';
}
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section
class="sheet"
role="dialog"
aria-modal="true"
aria-labelledby="comment-sheet-title"
>
<div class="handle" aria-hidden="true" />
<header class="head">
<button class="icon" type="button" aria-label="ثبت نظر" :disabled="!canSubmit" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<h2 id="comment-sheet-title">ثبت نظر</h2>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="body">
<article class="ctx">
<div class="ctx-copy">
<h3>{{ activity.title }}</h3>
<p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" />
{{ subtitle }}
</p>
<div class="meta">
<span>
<UiIcon name="user" :size="14" />
{{ activity.assignee?.fullName || 'بدون مسئول' }}
</span>
<i />
<span>
<UiIcon name="calendar" :size="14" />
{{ dueLabel }}
</span>
</div>
</div>
<span class="type" :class="activity.type">
<UiIcon :name="typeIcon[activity.type]" :size="22" />
</span>
</article>
<div class="editor">
<textarea
ref="textareaRef"
v-model="comment"
:disabled="recording"
rows="6"
placeholder="متن خود را بنویسید..."
/>
<div class="tools">
<div class="tool-wrap">
<button
class="tool"
type="button"
aria-label="منشن"
:disabled="recording"
@click="mentionOpen = !mentionOpen"
>
@
</button>
<ul v-if="mentionOpen" class="mentions">
<li v-if="!users.length" class="empty">کاربری برای منشن یافت نشد.</li>
<li v-for="user in users" :key="user.id">
<button type="button" @click="insertMention(user.fullName)">
{{ user.fullName }}
</button>
</li>
</ul>
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@error="voiceError = $event"
/>
</div>
</div>
</div>
<p v-if="voiceError" class="voice-err">{{ voiceError }}</p>
<p class="notice">
<UiIcon name="lightbulb" :size="18" />
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
</p>
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
</div>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.layer {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
justify-content: center;
align-items: stretch;
}
.backdrop {
display: none;
}
.sheet {
width: 100%;
height: 100dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
}
.handle {
width: 42px;
height: 4px;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: #d4d4d8;
}
.head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
padding: 0.15rem 0.7rem 0.45rem;
}
.head h2 {
margin: 0;
text-align: center;
font-size: 16px;
font-weight: 500;
color: #0f172a;
}
.icon {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.body {
flex: 1;
overflow: auto;
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
align-content: start;
gap: 0.85rem;
}
.ctx {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.9rem 0.95rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.type {
width: 46px;
height: 46px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #fff;
}
.type.call {
background: #0d9488;
}
.type.meeting {
background: #7c3aed;
}
.type.task {
background: #ea580c;
}
.type.note {
background: #2563eb;
}
.ctx-copy {
min-width: 0;
flex: 1;
}
.ctx-copy h3 {
margin: 0.1rem 0 0;
font-size: 0.98rem;
font-weight: 600;
line-height: 1.55;
}
.sub,
.meta span {
display: flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.8rem;
}
.sub {
margin: 0.28rem 0 0;
}
.meta {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.7rem;
padding-top: 0.65rem;
border-top: 1px solid #f1f5f9;
}
.meta i {
width: 1px;
height: 14px;
background: #e2e8f0;
}
.editor {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
min-height: 11.5rem;
display: flex;
flex-direction: column;
}
.editor textarea {
flex: 1;
width: 100%;
min-height: 8.5rem;
border: 0;
resize: none;
padding: 0.9rem 0.95rem 0.2rem;
font: inherit;
font-size: 0.95rem;
line-height: 1.9;
color: #0f172a;
background: transparent;
}
.editor textarea:focus {
outline: none;
}
.editor textarea::placeholder {
color: #94a3b8;
}
.tools {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0.35rem 0.5rem 0.5rem;
}
.tool-wrap {
position: relative;
}
.tool {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tool-wrap :deep(.vtt:not(.on):not(.busy)) {
color: #0d9488;
}
.tip {
position: absolute;
inset-inline-end: 0;
bottom: calc(100% + 8px);
margin: 0;
white-space: nowrap;
background: #111827;
color: #fff;
font-size: 0.74rem;
font-weight: 700;
padding: 0.42rem 0.65rem;
border-radius: var(--radius);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
.tip::after {
content: '';
position: absolute;
inset-inline-end: 14px;
top: 100%;
border: 6px solid transparent;
border-top-color: #111827;
}
.mentions {
position: absolute;
inset-inline-start: 0;
bottom: calc(100% + 6px);
margin: 0;
padding: 0.3rem;
list-style: none;
min-width: 11.5rem;
max-height: 12rem;
overflow: auto;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
z-index: 2;
}
.mentions button,
.mentions .empty {
width: 100%;
border: 0;
background: none;
text-align: start;
font: inherit;
padding: 0.55rem 0.65rem;
border-radius: var(--radius);
color: #0f172a;
cursor: pointer;
}
.mentions .empty {
color: #94a3b8;
cursor: default;
}
.mentions button:hover {
background: #f1f5f9;
}
.voice-err {
margin: -0.35rem 0 0;
color: #b91c1c;
font-size: 0.8rem;
}
.notice {
margin: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.72rem 0.8rem;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
line-height: 1.7;
}
.notice :deep(.ico) {
margin-top: 0.12rem;
flex-shrink: 0;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.act {
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.act.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.act.ghost {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
align-items: center;
padding: 1.2rem;
}
.backdrop {
display: block;
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.sheet {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: var(--radius, 10px);
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
</style>