Creating a mobile menu at the bottom of the app
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsEmail,
|
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
@@ -14,8 +14,11 @@ const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
|
|||||||
const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const;
|
const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const;
|
||||||
|
|
||||||
export class InviteUserDto {
|
export class InviteUserDto {
|
||||||
@IsEmail()
|
@IsString()
|
||||||
email!: string;
|
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||||
|
message: 'شماره موبایل معتبر نیست',
|
||||||
|
})
|
||||||
|
mobile!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import {
|
|||||||
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
|
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { normalizeMobile } from '../auth/auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
function inviteEmailFromMobile(mobile: string): string {
|
||||||
|
return `${mobile}@users.mento.local`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -31,30 +36,43 @@ export class UsersService {
|
|||||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند کاربر اضافه کند');
|
throw new ForbiddenException('فقط مالک یا مدیر میتواند کاربر اضافه کند');
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = dto.email.trim().toLowerCase();
|
const mobile = normalizeMobile(dto.mobile);
|
||||||
const existing = await this.prisma.user.findFirst({
|
if (!mobile) {
|
||||||
where: { organizationId, email },
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('این ایمیل در سازمان شما وجود دارد');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const global = await this.prisma.user.findFirst({ where: { email } });
|
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
|
||||||
if (global) {
|
if (existingMobile) {
|
||||||
throw new ConflictException('این ایمیل قبلاً در سیستم ثبت شده است');
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = inviteEmailFromMobile(mobile);
|
||||||
|
const existingEmail = await this.prisma.user.findFirst({
|
||||||
|
where: { organizationId, email },
|
||||||
|
});
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
try {
|
||||||
const row = await this.prisma.user.create({
|
const row = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
email,
|
email,
|
||||||
|
mobile,
|
||||||
fullName: dto.fullName.trim(),
|
fullName: dto.fullName.trim(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCommission(
|
async updateCommission(
|
||||||
@@ -122,6 +140,7 @@ export class UsersService {
|
|||||||
private toDto(row: {
|
private toDto(row: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
mobile?: string | null;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
commissionPercent?: { toString(): string } | number;
|
commissionPercent?: { toString(): string } | number;
|
||||||
@@ -130,6 +149,7 @@ export class UsersService {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
mobile: row.mobile ?? null,
|
||||||
fullName: row.fullName,
|
fullName: row.fullName,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
commissionPercent: Number(row.commissionPercent ?? 0),
|
commissionPercent: Number(row.commissionPercent ?? 0),
|
||||||
|
|||||||
@@ -75,17 +75,22 @@ let dragOffsetX = 0;
|
|||||||
let dragOffsetY = 0;
|
let dragOffsetY = 0;
|
||||||
|
|
||||||
function defaultPos(): FabPos {
|
function defaultPos(): FabPos {
|
||||||
|
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
|
||||||
return {
|
return {
|
||||||
left: FAB_MARGIN,
|
left: FAB_MARGIN,
|
||||||
top: Math.max(FAB_MARGIN, viewport.value.h - FAB_MARGIN - FAB_SIZE),
|
top: Math.max(FAB_MARGIN, viewport.value.h - bottomGap - FAB_SIZE),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampPos(pos: FabPos): FabPos {
|
function clampPos(pos: FabPos): FabPos {
|
||||||
const margin = 8;
|
const margin = 8;
|
||||||
|
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
|
||||||
return {
|
return {
|
||||||
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
|
||||||
top: Math.min(Math.max(margin, pos.top), viewport.value.h - FAB_SIZE - margin),
|
top: Math.min(
|
||||||
|
Math.max(margin, pos.top),
|
||||||
|
viewport.value.h - FAB_SIZE - margin - bottomGap,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: min(86dvh, calc(100dvh - 3.5rem));
|
max-height: min(86dvh, calc(100dvh - 3.5rem));
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.85rem 0.9rem calc(0.9rem + env(safe-area-inset-bottom));
|
padding: 0.85rem 0.9rem 0.9rem;
|
||||||
border-radius: 24px 24px 0 0;
|
border-radius: 24px 24px 0 0;
|
||||||
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
|
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ function pathIn(prefixes: string[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dashboardActive = computed(() => route.path === '/');
|
||||||
|
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||||
|
const reportsActive = computed(() => pathIn(['/reports']));
|
||||||
const customersActive = computed(() => pathIn(['/contacts']));
|
const customersActive = computed(() => pathIn(['/contacts']));
|
||||||
const salesActive = computed(() =>
|
const salesActive = computed(() =>
|
||||||
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
|
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
|
||||||
@@ -65,6 +68,17 @@ const settingsActive = computed(() =>
|
|||||||
'/settings/faq',
|
'/settings/faq',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const moreSectionActive = computed(
|
||||||
|
() =>
|
||||||
|
notesActive.value ||
|
||||||
|
supportActive.value ||
|
||||||
|
loyaltyActive.value ||
|
||||||
|
reportsActive.value ||
|
||||||
|
automationsActive.value ||
|
||||||
|
toolsActive.value ||
|
||||||
|
settingsActive.value,
|
||||||
|
);
|
||||||
|
|
||||||
const openHub = computed<NavHubDef | null>(() => {
|
const openHub = computed<NavHubDef | null>(() => {
|
||||||
if (!openHubKey.value) return null;
|
if (!openHubKey.value) return null;
|
||||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||||
@@ -118,7 +132,17 @@ function closeHub() {
|
|||||||
|
|
||||||
function onHubClick(key: NavHubKey) {
|
function onHubClick(key: NavHubKey) {
|
||||||
openHubKey.value = openHubKey.value === key ? null : key;
|
openHubKey.value = openHubKey.value === key ? null : key;
|
||||||
closeMenu();
|
}
|
||||||
|
|
||||||
|
function openTabHub(key: NavHubKey) {
|
||||||
|
moreOpen.value = false;
|
||||||
|
notifOpen.value = false;
|
||||||
|
onHubClick(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoreHub(key: NavHubKey) {
|
||||||
|
notifOpen.value = false;
|
||||||
|
onHubClick(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHub(to: string) {
|
function goHub(to: string) {
|
||||||
@@ -127,17 +151,23 @@ function goHub(to: string) {
|
|||||||
void router.push(to);
|
void router.push(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuOpen = ref(false);
|
const moreOpen = ref(false);
|
||||||
const notifOpen = ref(false);
|
const notifOpen = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const unreadCount = ref(0);
|
const unreadCount = ref(0);
|
||||||
const items = ref<NotificationDto[]>([]);
|
const items = ref<NotificationDto[]>([]);
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let mobileMq: MediaQueryList | null = null;
|
||||||
|
|
||||||
const hasUnread = computed(() => unreadCount.value > 0);
|
const hasUnread = computed(() => unreadCount.value > 0);
|
||||||
|
const moreTabActive = computed(() => moreOpen.value || moreSectionActive.value);
|
||||||
|
|
||||||
|
function closeMore() {
|
||||||
|
moreOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
menuOpen.value = false;
|
closeMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCollapsed() {
|
function toggleCollapsed() {
|
||||||
@@ -153,6 +183,10 @@ function closeNotif() {
|
|||||||
notifOpen.value = false;
|
notifOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMobileMqChange(event: MediaQueryListEvent) {
|
||||||
|
if (!event.matches) closeMore();
|
||||||
|
}
|
||||||
|
|
||||||
function onLogout() {
|
function onLogout() {
|
||||||
closeHub();
|
closeHub();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
@@ -206,13 +240,18 @@ async function markAll() {
|
|||||||
unreadCount.value = 0;
|
unreadCount.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMore() {
|
||||||
notifOpen.value = false;
|
notifOpen.value = false;
|
||||||
menuOpen.value = !menuOpen.value;
|
if (moreOpen.value) {
|
||||||
|
moreOpen.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeHub();
|
||||||
|
moreOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleNotif() {
|
function toggleNotif() {
|
||||||
menuOpen.value = false;
|
moreOpen.value = false;
|
||||||
notifOpen.value = !notifOpen.value;
|
notifOpen.value = !notifOpen.value;
|
||||||
if (notifOpen.value) void refreshNotifs();
|
if (notifOpen.value) void refreshNotifs();
|
||||||
}
|
}
|
||||||
@@ -235,10 +274,12 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBodyLock() {
|
function syncBodyLock() {
|
||||||
|
const overlay = moreOpen.value || Boolean(openHubKey.value);
|
||||||
document.body.style.overflow =
|
document.body.style.overflow =
|
||||||
menuOpen.value || notifOpen.value || Boolean(openHubKey.value) ? 'hidden' : '';
|
overlay || notifOpen.value ? 'hidden' : '';
|
||||||
|
document.body.classList.toggle('mobile-nav-overlay', overlay);
|
||||||
}
|
}
|
||||||
watch(menuOpen, syncBodyLock);
|
watch(moreOpen, syncBodyLock);
|
||||||
watch(notifOpen, syncBodyLock);
|
watch(notifOpen, syncBodyLock);
|
||||||
watch(openHubKey, syncBodyLock);
|
watch(openHubKey, syncBodyLock);
|
||||||
watch(
|
watch(
|
||||||
@@ -267,20 +308,23 @@ onMounted(() => {
|
|||||||
const meta = profileNameEl.value?.parentElement;
|
const meta = profileNameEl.value?.parentElement;
|
||||||
if (meta) profileNameObserver.observe(meta);
|
if (meta) profileNameObserver.observe(meta);
|
||||||
}
|
}
|
||||||
|
mobileMq = window.matchMedia('(max-width: 900px)');
|
||||||
|
mobileMq.addEventListener('change', onMobileMqChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('mobile-nav-overlay');
|
||||||
profileNameObserver?.disconnect();
|
profileNameObserver?.disconnect();
|
||||||
|
mobileMq?.removeEventListener('change', onMobileMqChange);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="shell" :class="{ 'menu-open': menuOpen, collapsed, 'hub-open': Boolean(openHub) }">
|
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
|
||||||
<div v-if="menuOpen" class="backdrop" aria-hidden="true" @click="closeMenu" />
|
|
||||||
|
|
||||||
<aside id="app-sidebar" class="sidebar" :class="{ open: menuOpen, collapsed }">
|
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<div class="brand-cluster">
|
<div class="brand-cluster">
|
||||||
<MentoLogo class="sidebar-logo" :size="32" />
|
<MentoLogo class="sidebar-logo" :size="32" />
|
||||||
@@ -499,24 +543,34 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<header class="mobile-topbar mobile-only">
|
<header class="mobile-topbar mobile-only">
|
||||||
<button
|
|
||||||
class="round-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">
|
<div class="top-brand">
|
||||||
<MentoLogo :size="32" :show-text="false" variant="mark" />
|
<MentoLogo :size="32" :show-text="false" variant="mark" />
|
||||||
<BrandName lang="en" />
|
<BrandName lang="en" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="top-tools">
|
||||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||||
<UiIcon name="bell" :size="18" />
|
<UiIcon name="bell" :size="18" />
|
||||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<RouterLink
|
||||||
|
class="top-avatar"
|
||||||
|
:to="{ name: 'profile' }"
|
||||||
|
:title="auth.user?.fullName || 'پروفایل'"
|
||||||
|
:aria-label="auth.user?.fullName || 'پروفایل'"
|
||||||
|
>
|
||||||
|
<span class="avatar-wrap">
|
||||||
|
<span class="avatar">
|
||||||
|
<img
|
||||||
|
src="/brand/user-avatar.png"
|
||||||
|
:alt="auth.user?.fullName || 'کاربر'"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<i class="online-dot" title="آنلاین" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<header class="desktop-topbar desktop-only">
|
<header class="desktop-topbar desktop-only">
|
||||||
@@ -587,6 +641,153 @@ onUnmounted(() => {
|
|||||||
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
|
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
|
||||||
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
|
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="mobile-tabbar mobile-only" aria-label="منوی اصلی">
|
||||||
|
<RouterLink
|
||||||
|
to="/"
|
||||||
|
class="tab"
|
||||||
|
:class="{ on: dashboardActive && !moreOpen }"
|
||||||
|
@click="closeHub(); closeMore()"
|
||||||
|
>
|
||||||
|
<UiIcon name="home" :size="20" />
|
||||||
|
<span>داشبورد</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
type="button"
|
||||||
|
:class="{ on: !moreOpen && (customersActive || openHubKey === 'customers') }"
|
||||||
|
:aria-expanded="openHubKey === 'customers'"
|
||||||
|
@click="openTabHub('customers')"
|
||||||
|
>
|
||||||
|
<UiIcon name="users" :size="20" />
|
||||||
|
<span>مشتریان</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
type="button"
|
||||||
|
:class="{ on: !moreOpen && (salesActive || openHubKey === 'sales') }"
|
||||||
|
:aria-expanded="openHubKey === 'sales'"
|
||||||
|
@click="openTabHub('sales')"
|
||||||
|
>
|
||||||
|
<UiIcon name="briefcase" :size="20" />
|
||||||
|
<span>فروش</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
type="button"
|
||||||
|
:class="{ on: !moreOpen && (activitiesActive || openHubKey === 'activities') }"
|
||||||
|
:aria-expanded="openHubKey === 'activities'"
|
||||||
|
@click="openTabHub('activities')"
|
||||||
|
>
|
||||||
|
<span class="tab-ico">
|
||||||
|
<UiIcon name="check" :size="20" />
|
||||||
|
<span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span>
|
||||||
|
</span>
|
||||||
|
<span>فعالیتها</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
type="button"
|
||||||
|
:class="{ on: moreTabActive }"
|
||||||
|
:aria-expanded="moreOpen"
|
||||||
|
aria-controls="mobile-more"
|
||||||
|
@click="toggleMore"
|
||||||
|
>
|
||||||
|
<UiIcon name="grid" :size="20" />
|
||||||
|
<span>بیشتر</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="moreOpen"
|
||||||
|
id="mobile-more"
|
||||||
|
class="more-layer mobile-only"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="منو"
|
||||||
|
>
|
||||||
|
<header class="more-head">
|
||||||
|
<h2>منو</h2>
|
||||||
|
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
|
||||||
|
</header>
|
||||||
|
<nav class="more-body">
|
||||||
|
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||||
|
<UiIcon name="document" :size="18" />
|
||||||
|
<span>یادداشتها</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="nav-item nav-fold-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{
|
||||||
|
'router-link-active': supportActive || openHubKey === 'support',
|
||||||
|
'hub-on': openHubKey === 'support',
|
||||||
|
}"
|
||||||
|
:aria-expanded="openHubKey === 'support'"
|
||||||
|
@click="openMoreHub('support')"
|
||||||
|
>
|
||||||
|
<UiIcon name="chat" :size="18" />
|
||||||
|
<span>پشتیبانی</span>
|
||||||
|
<i class="chev" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<RouterLink to="/loyalty" class="nav-item" @click="closeHub(); closeMore()">
|
||||||
|
<UiIcon name="gift" :size="18" />
|
||||||
|
<span>باشگاه مشتریان</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/reports" class="nav-item" @click="closeHub(); closeMore()">
|
||||||
|
<UiIcon name="chart" :size="18" />
|
||||||
|
<span>گزارشها</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
v-if="canSettings"
|
||||||
|
class="nav-item nav-fold-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{
|
||||||
|
'router-link-active': automationsActive || openHubKey === 'automations',
|
||||||
|
'hub-on': openHubKey === 'automations',
|
||||||
|
}"
|
||||||
|
:aria-expanded="openHubKey === 'automations'"
|
||||||
|
@click="openMoreHub('automations')"
|
||||||
|
>
|
||||||
|
<UiIcon name="layers" :size="18" />
|
||||||
|
<span>اتوماسیون</span>
|
||||||
|
<i class="chev" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-item nav-fold-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{
|
||||||
|
'router-link-active': toolsActive || openHubKey === 'tools',
|
||||||
|
'hub-on': openHubKey === 'tools',
|
||||||
|
}"
|
||||||
|
:aria-expanded="openHubKey === 'tools'"
|
||||||
|
@click="openMoreHub('tools')"
|
||||||
|
>
|
||||||
|
<UiIcon name="apps-plus" :size="18" />
|
||||||
|
<span>ابزارها</span>
|
||||||
|
<i class="chev" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canSettings"
|
||||||
|
class="nav-item nav-fold-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{
|
||||||
|
'router-link-active': settingsActive || openHubKey === 'settings',
|
||||||
|
'hub-on': openHubKey === 'settings',
|
||||||
|
}"
|
||||||
|
:aria-expanded="openHubKey === 'settings'"
|
||||||
|
@click="openMoreHub('settings')"
|
||||||
|
>
|
||||||
|
<UiIcon name="settings" :size="18" />
|
||||||
|
<span>تنظیمات</span>
|
||||||
|
<i class="chev" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<button class="logout-link more-logout" type="button" @click="onLogout">
|
||||||
|
<UiIcon name="logout" :size="18" />
|
||||||
|
<span>خروج از حساب</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<QuickCreate />
|
<QuickCreate />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1584,8 +1785,35 @@ onUnmounted(() => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: var(--control-h);
|
||||||
|
height: var(--control-h);
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-avatar .avatar-wrap,
|
||||||
|
.top-avatar .avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar,
|
||||||
|
.more-layer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.shell {
|
.shell {
|
||||||
|
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1597,35 +1825,13 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backdrop {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(15, 23, 42, 0.35);
|
|
||||||
z-index: 34;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
display: none;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
width: min(300px, 86vw);
|
|
||||||
height: 100dvh;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
z-index: 35;
|
|
||||||
transform: translateX(110%);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-layer {
|
.hub-layer {
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
|
bottom: var(--mobile-tabbar-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-layer :deep(.hub) {
|
.hub-layer :deep(.hub) {
|
||||||
@@ -1634,6 +1840,8 @@ onUnmounted(() => {
|
|||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding-bottom: 0.9rem;
|
||||||
|
max-height: min(86dvh, calc(100dvh - var(--mobile-tabbar-h) - 2rem));
|
||||||
animation: hub-sheet-up 0.28s ease;
|
animation: hub-sheet-up 0.28s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,16 +1855,153 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 1rem 0.85rem 1.4rem;
|
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-layer {
|
.notif-layer {
|
||||||
padding: 4.25rem 0.65rem 1rem;
|
padding: 4.25rem 0.65rem calc(1rem + var(--mobile-tabbar-h));
|
||||||
place-items: start stretch;
|
place-items: start stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-panel {
|
.notif-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 45;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
height: var(--mobile-tabbar-h);
|
||||||
|
min-height: var(--mobile-tabbar-h);
|
||||||
|
padding: 0.28rem 0.2rem calc(0.28rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e8eef3;
|
||||||
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.12rem;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0.12rem;
|
||||||
|
padding: 0.28rem 0.1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab > span:not(.tab-ico) {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:active,
|
||||||
|
.tab.on {
|
||||||
|
background: #d8faf3;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:active :deep(.ico),
|
||||||
|
.tab.on :deep(.ico) {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-ico {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.35rem;
|
||||||
|
inset-inline-start: 0.85rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #14b8a6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 var(--mobile-tabbar-h) 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 1rem 0.85rem;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8eef3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.55rem 0.5rem;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-body .nav-item:active {
|
||||||
|
background: #d8faf3;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-logout {
|
||||||
|
justify-self: start;
|
||||||
|
margin: 0.15rem 0.85rem 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.mobile-nav-overlay .qc {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const businessField = ref('');
|
|||||||
const orgSaving = ref(false);
|
const orgSaving = ref(false);
|
||||||
const orgOk = ref('');
|
const orgOk = ref('');
|
||||||
|
|
||||||
const email = ref('');
|
const mobile = ref('');
|
||||||
const fullName = ref('');
|
const fullName = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const role = ref<Exclude<UserRole, 'owner'>>('sales');
|
const role = ref<Exclude<UserRole, 'owner'>>('sales');
|
||||||
@@ -100,21 +100,21 @@ async function saveOrg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function invite() {
|
async function invite() {
|
||||||
if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) {
|
if (!mobile.value.trim() || !fullName.value.trim() || password.value.length < 8) {
|
||||||
error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.';
|
error.value = 'نام، شماره موبایل و رمز حداقل ۸ کاراکتر لازم است.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const payload: InviteUserRequest = {
|
const payload: InviteUserRequest = {
|
||||||
email: email.value.trim(),
|
mobile: mobile.value.trim(),
|
||||||
fullName: fullName.value.trim(),
|
fullName: fullName.value.trim(),
|
||||||
password: password.value,
|
password: password.value,
|
||||||
role: role.value,
|
role: role.value,
|
||||||
};
|
};
|
||||||
await api.post('/users', payload);
|
await api.post('/users', payload);
|
||||||
email.value = '';
|
mobile.value = '';
|
||||||
fullName.value = '';
|
fullName.value = '';
|
||||||
password.value = '';
|
password.value = '';
|
||||||
role.value = 'sales';
|
role.value = 'sales';
|
||||||
@@ -214,8 +214,16 @@ onMounted(load);
|
|||||||
<input v-model="fullName" required maxlength="120" />
|
<input v-model="fullName" required maxlength="120" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
ایمیل
|
شماره موبایل
|
||||||
<input v-model="email" type="email" required />
|
<input
|
||||||
|
v-model="mobile"
|
||||||
|
type="tel"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="0912xxxxxxx"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
رمز عبور موقت
|
رمز عبور موقت
|
||||||
@@ -239,7 +247,7 @@ onMounted(load);
|
|||||||
<article v-for="user in users" :key="user.id" class="row">
|
<article v-for="user in users" :key="user.id" class="row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ user.fullName }}</strong>
|
<strong>{{ user.fullName }}</strong>
|
||||||
<p class="muted">{{ user.email }}</p>
|
<p class="muted">{{ user.mobile || user.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="role-cell">
|
<div class="role-cell">
|
||||||
<label class="comm-field">
|
<label class="comm-field">
|
||||||
@@ -405,22 +413,38 @@ select {
|
|||||||
|
|
||||||
.role-cell {
|
.role-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.65rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comm-field {
|
.comm-field {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 0.15rem;
|
flex-direction: row;
|
||||||
font-size: 0.75rem;
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comm-field input,
|
||||||
|
.role-cell select {
|
||||||
|
height: var(--control-h);
|
||||||
|
min-height: var(--control-h);
|
||||||
|
}
|
||||||
|
|
||||||
.comm-field input {
|
.comm-field input {
|
||||||
width: 4.8rem;
|
width: 4.8rem;
|
||||||
padding: 0 0.45rem;
|
padding: 0 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-cell select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 6.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.role-pill {
|
.role-pill {
|
||||||
background: #f0fdfa;
|
background: #f0fdfa;
|
||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface AuthUserDto {
|
|||||||
export interface OrgUserDto {
|
export interface OrgUserDto {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
mobile?: string | null;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
commissionPercent?: number;
|
commissionPercent?: number;
|
||||||
@@ -51,7 +52,7 @@ export interface UpdateUserCommissionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteUserRequest {
|
export interface InviteUserRequest {
|
||||||
email: string;
|
mobile: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: Exclude<UserRole, "owner">;
|
role: Exclude<UserRole, "owner">;
|
||||||
|
|||||||
Reference in New Issue
Block a user