Files
CRM_Montzar/apps/web/src/components/activities/ActivityCommentSheet.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30

608 lines
13 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, 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>