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 {
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
@@ -14,8 +14,11 @@ const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
|
||||
const ALL_ROLES = ['owner', 'admin', 'sales', 'support'] as const;
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
@IsString()
|
||||
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||
message: 'شماره موبایل معتبر نیست',
|
||||
})
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { normalizeMobile } from '../auth/auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function inviteEmailFromMobile(mobile: string): string {
|
||||
return `${mobile}@users.mento.local`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -31,30 +36,43 @@ export class UsersService {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند کاربر اضافه کند');
|
||||
}
|
||||
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: { organizationId, email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('این ایمیل در سازمان شما وجود دارد');
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const global = await this.prisma.user.findFirst({ where: { email } });
|
||||
if (global) {
|
||||
throw new ConflictException('این ایمیل قبلاً در سیستم ثبت شده است');
|
||||
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
if (existingMobile) {
|
||||
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 row = await this.prisma.user.create({
|
||||
data: {
|
||||
organizationId,
|
||||
email,
|
||||
fullName: dto.fullName.trim(),
|
||||
passwordHash,
|
||||
role: dto.role,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
try {
|
||||
const row = await this.prisma.user.create({
|
||||
data: {
|
||||
organizationId,
|
||||
email,
|
||||
mobile,
|
||||
fullName: dto.fullName.trim(),
|
||||
passwordHash,
|
||||
role: dto.role,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCommission(
|
||||
@@ -122,6 +140,7 @@ export class UsersService {
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
commissionPercent?: { toString(): string } | number;
|
||||
@@ -130,6 +149,7 @@ export class UsersService {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
mobile: row.mobile ?? null,
|
||||
fullName: row.fullName,
|
||||
role: row.role,
|
||||
commissionPercent: Number(row.commissionPercent ?? 0),
|
||||
|
||||
@@ -75,17 +75,22 @@ let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
|
||||
function defaultPos(): FabPos {
|
||||
const bottomGap = viewport.value.w <= 900 ? FAB_MARGIN + 64 : FAB_MARGIN;
|
||||
return {
|
||||
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 {
|
||||
const margin = 8;
|
||||
const bottomGap = viewport.value.w <= 900 ? 64 : 0;
|
||||
return {
|
||||
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%;
|
||||
max-height: min(86dvh, calc(100dvh - 3.5rem));
|
||||
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;
|
||||
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 salesActive = computed(() =>
|
||||
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
|
||||
@@ -65,6 +68,17 @@ const settingsActive = computed(() =>
|
||||
'/settings/faq',
|
||||
]),
|
||||
);
|
||||
const moreSectionActive = computed(
|
||||
() =>
|
||||
notesActive.value ||
|
||||
supportActive.value ||
|
||||
loyaltyActive.value ||
|
||||
reportsActive.value ||
|
||||
automationsActive.value ||
|
||||
toolsActive.value ||
|
||||
settingsActive.value,
|
||||
);
|
||||
|
||||
const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
@@ -118,7 +132,17 @@ function closeHub() {
|
||||
|
||||
function onHubClick(key: NavHubKey) {
|
||||
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) {
|
||||
@@ -127,17 +151,23 @@ function goHub(to: string) {
|
||||
void router.push(to);
|
||||
}
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const moreOpen = 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;
|
||||
let mobileMq: MediaQueryList | null = null;
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const moreTabActive = computed(() => moreOpen.value || moreSectionActive.value);
|
||||
|
||||
function closeMore() {
|
||||
moreOpen.value = false;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false;
|
||||
closeMore();
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
@@ -153,6 +183,10 @@ function closeNotif() {
|
||||
notifOpen.value = false;
|
||||
}
|
||||
|
||||
function onMobileMqChange(event: MediaQueryListEvent) {
|
||||
if (!event.matches) closeMore();
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
closeHub();
|
||||
closeMenu();
|
||||
@@ -206,13 +240,18 @@ async function markAll() {
|
||||
unreadCount.value = 0;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
function toggleMore() {
|
||||
notifOpen.value = false;
|
||||
menuOpen.value = !menuOpen.value;
|
||||
if (moreOpen.value) {
|
||||
moreOpen.value = false;
|
||||
return;
|
||||
}
|
||||
closeHub();
|
||||
moreOpen.value = true;
|
||||
}
|
||||
|
||||
function toggleNotif() {
|
||||
menuOpen.value = false;
|
||||
moreOpen.value = false;
|
||||
notifOpen.value = !notifOpen.value;
|
||||
if (notifOpen.value) void refreshNotifs();
|
||||
}
|
||||
@@ -235,10 +274,12 @@ function submitSearch() {
|
||||
}
|
||||
|
||||
function syncBodyLock() {
|
||||
const overlay = moreOpen.value || Boolean(openHubKey.value);
|
||||
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(openHubKey, syncBodyLock);
|
||||
watch(
|
||||
@@ -267,20 +308,23 @@ onMounted(() => {
|
||||
const meta = profileNameEl.value?.parentElement;
|
||||
if (meta) profileNameObserver.observe(meta);
|
||||
}
|
||||
mobileMq = window.matchMedia('(max-width: 900px)');
|
||||
mobileMq.addEventListener('change', onMobileMqChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('mobile-nav-overlay');
|
||||
profileNameObserver?.disconnect();
|
||||
mobileMq?.removeEventListener('change', onMobileMqChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell" :class="{ 'menu-open': menuOpen, collapsed, 'hub-open': Boolean(openHub) }">
|
||||
<div v-if="menuOpen" class="backdrop" aria-hidden="true" @click="closeMenu" />
|
||||
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
|
||||
|
||||
<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-cluster">
|
||||
<MentoLogo class="sidebar-logo" :size="32" />
|
||||
@@ -499,24 +543,34 @@ onUnmounted(() => {
|
||||
|
||||
<div class="workspace">
|
||||
<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">
|
||||
<MentoLogo :size="32" :show-text="false" variant="mark" />
|
||||
<BrandName lang="en" />
|
||||
</div>
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
</button>
|
||||
<div class="top-tools">
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
</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 class="desktop-topbar desktop-only">
|
||||
@@ -587,6 +641,153 @@ onUnmounted(() => {
|
||||
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
|
||||
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
|
||||
</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 />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1584,8 +1785,35 @@ onUnmounted(() => {
|
||||
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) {
|
||||
.shell {
|
||||
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -1597,35 +1825,13 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 34;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
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);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hub-layer {
|
||||
z-index: 80;
|
||||
bottom: var(--mobile-tabbar-h);
|
||||
}
|
||||
|
||||
.hub-layer :deep(.hub) {
|
||||
@@ -1634,6 +1840,8 @@ onUnmounted(() => {
|
||||
inset-inline: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-bottom: 0.9rem;
|
||||
max-height: min(86dvh, calc(100dvh - var(--mobile-tabbar-h) - 2rem));
|
||||
animation: hub-sheet-up 0.28s ease;
|
||||
}
|
||||
|
||||
@@ -1647,16 +1855,153 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 0.85rem 1.4rem;
|
||||
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
|
||||
}
|
||||
|
||||
.notif-layer {
|
||||
padding: 4.25rem 0.65rem 1rem;
|
||||
padding: 4.25rem 0.65rem calc(1rem + var(--mobile-tabbar-h));
|
||||
place-items: start stretch;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
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>
|
||||
|
||||
@@ -24,7 +24,7 @@ const businessField = ref('');
|
||||
const orgSaving = ref(false);
|
||||
const orgOk = ref('');
|
||||
|
||||
const email = ref('');
|
||||
const mobile = ref('');
|
||||
const fullName = ref('');
|
||||
const password = ref('');
|
||||
const role = ref<Exclude<UserRole, 'owner'>>('sales');
|
||||
@@ -100,21 +100,21 @@ async function saveOrg() {
|
||||
}
|
||||
|
||||
async function invite() {
|
||||
if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) {
|
||||
error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.';
|
||||
if (!mobile.value.trim() || !fullName.value.trim() || password.value.length < 8) {
|
||||
error.value = 'نام، شماره موبایل و رمز حداقل ۸ کاراکتر لازم است.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const payload: InviteUserRequest = {
|
||||
email: email.value.trim(),
|
||||
mobile: mobile.value.trim(),
|
||||
fullName: fullName.value.trim(),
|
||||
password: password.value,
|
||||
role: role.value,
|
||||
};
|
||||
await api.post('/users', payload);
|
||||
email.value = '';
|
||||
mobile.value = '';
|
||||
fullName.value = '';
|
||||
password.value = '';
|
||||
role.value = 'sales';
|
||||
@@ -214,8 +214,16 @@ onMounted(load);
|
||||
<input v-model="fullName" required maxlength="120" />
|
||||
</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>
|
||||
رمز عبور موقت
|
||||
@@ -239,7 +247,7 @@ onMounted(load);
|
||||
<article v-for="user in users" :key="user.id" class="row">
|
||||
<div>
|
||||
<strong>{{ user.fullName }}</strong>
|
||||
<p class="muted">{{ user.email }}</p>
|
||||
<p class="muted">{{ user.mobile || user.email }}</p>
|
||||
</div>
|
||||
<div class="role-cell">
|
||||
<label class="comm-field">
|
||||
@@ -405,22 +413,38 @@ select {
|
||||
|
||||
.role-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.comm-field {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.comm-field input,
|
||||
.role-cell select {
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.comm-field input {
|
||||
width: 4.8rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.role-cell select {
|
||||
width: auto;
|
||||
min-width: 6.6rem;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
|
||||
Reference in New Issue
Block a user