Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
||||
import { USER_ROLE_LABELS, canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const canSettings = computed(() => canManageSettings(auth.user?.role));
|
||||
const showOwnRecordsBanner = computed(
|
||||
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
|
||||
);
|
||||
const roleLabel = computed(() =>
|
||||
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : '',
|
||||
);
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const notifOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const unreadCount = ref(0);
|
||||
const items = ref<NotificationDto[]>([]);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false;
|
||||
}
|
||||
|
||||
function closeNotif() {
|
||||
notifOpen.value = false;
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
closeMenu();
|
||||
closeNotif();
|
||||
auth.logout();
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!auth.isAuthenticated) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<NotificationsListDto>('/notifications', {
|
||||
params: { limit: 20 },
|
||||
});
|
||||
items.value = data.items;
|
||||
unreadCount.value = data.unreadCount;
|
||||
} catch {
|
||||
// keep previous
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(item: NotificationDto) {
|
||||
if (!item.readAt) {
|
||||
await api.patch(`/notifications/${item.id}/read`);
|
||||
item.readAt = new Date().toISOString();
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
closeNotif();
|
||||
closeMenu();
|
||||
if (item.link) {
|
||||
await router.push(item.link);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
await api.patch('/notifications/read-all');
|
||||
items.value = items.value.map((n) => ({
|
||||
...n,
|
||||
readAt: n.readAt ?? new Date().toISOString(),
|
||||
}));
|
||||
unreadCount.value = 0;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
notifOpen.value = false;
|
||||
menuOpen.value = !menuOpen.value;
|
||||
}
|
||||
|
||||
function toggleNotif() {
|
||||
menuOpen.value = false;
|
||||
notifOpen.value = !notifOpen.value;
|
||||
if (notifOpen.value) void refresh();
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
watch(menuOpen, (open) => {
|
||||
document.body.style.overflow = open || notifOpen.value ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(notifOpen, (open) => {
|
||||
document.body.style.overflow = open || menuOpen.value ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMenu();
|
||||
closeNotif();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
pollTimer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 45000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell" :class="{ 'menu-open': menuOpen }">
|
||||
<header class="topbar">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-controls="app-sidebar"
|
||||
aria-label="منو"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span class="burger" :class="{ open: menuOpen }" />
|
||||
</button>
|
||||
|
||||
<div class="top-brand">
|
||||
<span class="mark">M</span>
|
||||
<strong>منتو</strong>
|
||||
</div>
|
||||
|
||||
<button class="icon-btn notif-top" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
اعلان
|
||||
<span v-if="hasUnread" class="badge">{{ unreadCount }}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="backdrop"
|
||||
aria-hidden="true"
|
||||
@click="closeMenu"
|
||||
/>
|
||||
|
||||
<aside id="app-sidebar" class="sidebar" :class="{ open: menuOpen }">
|
||||
<div class="brand desktop-only">
|
||||
<span class="mark">M</span>
|
||||
<div>
|
||||
<strong>منتو</strong>
|
||||
<small>Mento CRM</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-head mobile-only">
|
||||
<div class="brand">
|
||||
<span class="mark">M</span>
|
||||
<div>
|
||||
<strong>منتو</strong>
|
||||
<small>Mento CRM</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="icon-btn ghost" type="button" aria-label="بستن منو" @click="closeMenu">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav @click="closeMenu">
|
||||
<RouterLink to="/">خانه امروز</RouterLink>
|
||||
<RouterLink to="/activities">فعالیتها</RouterLink>
|
||||
<RouterLink to="/activities/calendar">تقویم فعالیتها</RouterLink>
|
||||
<RouterLink to="/pipeline">کاریز فروش</RouterLink>
|
||||
<RouterLink to="/sales/forecast">هدف و پیشبینی</RouterLink>
|
||||
<RouterLink to="/reports">گزارشات</RouterLink>
|
||||
<RouterLink to="/tickets">تیکتها</RouterLink>
|
||||
<RouterLink to="/quotes">پیشفاکتورها</RouterLink>
|
||||
<RouterLink to="/invoices">فاکتورها</RouterLink>
|
||||
<RouterLink to="/contacts">اشخاص و شرکتها</RouterLink>
|
||||
<RouterLink to="/notes">یادداشتها</RouterLink>
|
||||
<template v-if="canSettings">
|
||||
<RouterLink to="/settings/team">تیم و نقشها</RouterLink>
|
||||
<RouterLink to="/settings/lead-forms">فرمساز سرنخ</RouterLink>
|
||||
<RouterLink to="/settings/custom-fields">فیلدهای سفارشی</RouterLink>
|
||||
<RouterLink to="/settings/products">محصولات</RouterLink>
|
||||
<RouterLink to="/settings/currencies">ارزها</RouterLink>
|
||||
<RouterLink to="/settings/faq">FAQ</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<p v-if="showOwnRecordsBanner" class="scope-banner">
|
||||
فقط پروندههای خودتان را میبینید.
|
||||
</p>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div class="notif-wrap desktop-only">
|
||||
<button class="notif-btn" type="button" @click="toggleNotif">
|
||||
اعلانها
|
||||
<span v-if="hasUnread" class="badge">{{ unreadCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p>{{ auth.user?.fullName }}</p>
|
||||
<p v-if="roleLabel" class="role-line">{{ roleLabel }}</p>
|
||||
<button class="btn btn-ghost logout" type="button" @click="onLogout">خروج</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="notifOpen" class="notif-layer">
|
||||
<div class="notif-backdrop" @click="closeNotif" />
|
||||
<div class="notif-panel" role="dialog" aria-label="اعلانها">
|
||||
<header class="notif-head">
|
||||
<strong>اعلانها</strong>
|
||||
<div class="notif-head-actions">
|
||||
<button
|
||||
v-if="hasUnread"
|
||||
class="linkish"
|
||||
type="button"
|
||||
@click="markAll"
|
||||
>
|
||||
همه خوانده شد
|
||||
</button>
|
||||
<button class="icon-btn ghost" type="button" aria-label="بستن" @click="closeNotif">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
|
||||
<ul v-else-if="items.length" class="notif-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:class="{ unread: !item.readAt }"
|
||||
>
|
||||
<button type="button" @click="markRead(item)">
|
||||
<span class="title">{{ item.title }}</span>
|
||||
<span v-if="item.body" class="body">{{ item.body }}</span>
|
||||
<span class="time">{{ formatTime(item.createdAt) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">اعلانی نیست.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #13201b;
|
||||
color: #edf5f1;
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
color: #9fb3aa;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
color: #d7e4de;
|
||||
}
|
||||
|
||||
nav a.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scope-banner {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #9fb3aa;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-foot p {
|
||||
margin: 0;
|
||||
color: #b7c8c0;
|
||||
}
|
||||
|
||||
.role-line {
|
||||
font-size: 0.8rem;
|
||||
color: #8fa399 !important;
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: #edf5f1;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.notif-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #edf5f1;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 999px;
|
||||
background: #e11d48;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.notif-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: start end;
|
||||
padding: 4.5rem 0.75rem 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notif-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 18, 15, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
position: relative;
|
||||
width: min(360px, calc(100vw - 1.5rem));
|
||||
max-height: min(70vh, 480px);
|
||||
overflow: auto;
|
||||
background: #1b2a24;
|
||||
color: #edf5f1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notif-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.notif-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9fd4c0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.notif-list button {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.7rem;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.notif-list li.unread button {
|
||||
background: rgba(15, 92, 99, 0.35);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.body,
|
||||
.time,
|
||||
.muted {
|
||||
color: #9fb3aa;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
border-radius: 10px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
padding: 0 0.65rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.icon-btn.ghost {
|
||||
background: transparent;
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.burger {
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
position: relative;
|
||||
display: block;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.burger::before,
|
||||
.burger::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.burger::before {
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.burger::after {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.burger.open {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.burger.open::before {
|
||||
transform: translateY(6px) rotate(45deg);
|
||||
}
|
||||
|
||||
.burger.open::after {
|
||||
transform: translateY(-6px) rotate(-45deg);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 0.7rem 0.85rem;
|
||||
padding-top: max(0.7rem, env(safe-area-inset-top));
|
||||
background: #13201b;
|
||||
color: #edf5f1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.top-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-brand .mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notif-top {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 18, 15, 0.45);
|
||||
z-index: 34;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
width: min(300px, 86vw);
|
||||
z-index: 35;
|
||||
/* پنل RTL است؛ منو از سمت راست میآید */
|
||||
transform: translateX(110%);
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: none;
|
||||
padding-top: max(1rem, env(safe-area-inset-top));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 0.85rem 1.4rem;
|
||||
padding-bottom: max(1.4rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.notif-layer {
|
||||
padding: 4.25rem 0.65rem 1rem;
|
||||
place-items: start stretch;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
width: 100%;
|
||||
max-height: min(75vh, 560px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user