Improve Leads mobile UX and nest leads under Customers nav.
Restore the 5-tab mobile bar, add responsive lead cards with long-press select, and fix stats/filters layout.
This commit is contained in:
@@ -1,17 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { NavHubDef } from '@/lib/nav-hubs';
|
||||
import type { NavHubDef, NavHubKey } from '@/lib/nav-hubs';
|
||||
|
||||
defineProps<{
|
||||
hub: NavHubDef;
|
||||
/** اگر از هاب والد آمده (مثلاً مشتریان → لیدها) */
|
||||
canBack?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
back: [];
|
||||
go: [to: string];
|
||||
openHub: [key: NavHubKey];
|
||||
}>();
|
||||
|
||||
function onCard(card: NavHubDef['cards'][number]) {
|
||||
if (card.hub) {
|
||||
emit('openHub', card.hub);
|
||||
return;
|
||||
}
|
||||
emit('go', card.to);
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') emit('close');
|
||||
}
|
||||
@@ -29,7 +41,18 @@ onUnmounted(() => {
|
||||
<section class="hub" role="dialog" aria-modal="true" :aria-label="hub.title">
|
||||
<header class="head">
|
||||
<div class="copy">
|
||||
<h1>{{ hub.title }}</h1>
|
||||
<div class="title-row">
|
||||
<button
|
||||
v-if="canBack"
|
||||
type="button"
|
||||
class="back-btn"
|
||||
aria-label="بازگشت"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<UiIcon name="chevron" :size="16" class="back-chev" />
|
||||
</button>
|
||||
<h1>{{ hub.title }}</h1>
|
||||
</div>
|
||||
<p>{{ hub.desc }}</p>
|
||||
</div>
|
||||
<span class="head-ico" :class="hub.tint" aria-hidden="true">
|
||||
@@ -40,10 +63,10 @@ onUnmounted(() => {
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="card in hub.cards"
|
||||
:key="card.to + card.title"
|
||||
:key="(card.hub ?? card.to) + card.title"
|
||||
class="card"
|
||||
type="button"
|
||||
@click="emit('go', card.to)"
|
||||
@click="onCard(card)"
|
||||
>
|
||||
<span class="card-top">
|
||||
<span class="card-title">
|
||||
@@ -105,6 +128,36 @@ onUnmounted(() => {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.back-chev {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.copy p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -32,6 +32,8 @@ const SIDEBAR_KEY = 'mento_sidebar_collapsed';
|
||||
const collapsed = ref(false);
|
||||
|
||||
const openHubKey = ref<NavHubKey | null>(null);
|
||||
const hubStack = ref<NavHubKey[]>([]);
|
||||
const isMobileNav = ref(false);
|
||||
const globalSearch = ref('');
|
||||
const activityBadge = ref(0);
|
||||
|
||||
@@ -44,10 +46,12 @@ 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 leadsActive = computed(() =>
|
||||
pathIn(['/leads', '/settings/lead-stages', '/settings/lead-forms']),
|
||||
);
|
||||
const customersActive = computed(
|
||||
() => pathIn(['/contacts']) || (isMobileNav.value && leadsActive.value),
|
||||
);
|
||||
const salesActive = computed(() =>
|
||||
pathIn([
|
||||
'/pipeline',
|
||||
@@ -111,23 +115,6 @@ function moduleAllowed(module?: string) {
|
||||
return enabledModules.value.includes(module as never);
|
||||
}
|
||||
|
||||
const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
if (!hub) return null;
|
||||
return {
|
||||
...hub,
|
||||
cards: hub.cards.filter(
|
||||
(card) =>
|
||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||
),
|
||||
shortcuts: hub.shortcuts.filter(
|
||||
(item) =>
|
||||
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const hubVisible = computed(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const hub of NAV_HUBS) {
|
||||
@@ -139,6 +126,41 @@ const hubVisible = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
if (!hub) return null;
|
||||
const cards = hub.cards.filter(
|
||||
(card) =>
|
||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
||||
);
|
||||
if (
|
||||
hub.key === 'customers' &&
|
||||
isMobileNav.value &&
|
||||
hubVisible.value.leads
|
||||
) {
|
||||
cards.push({
|
||||
to: '/leads',
|
||||
hub: 'leads',
|
||||
title: 'لیدها',
|
||||
desc: 'مخاطبین بالقوه قبل از تبدیل به مشتری',
|
||||
icon: 'user-plus',
|
||||
tint: 'sky',
|
||||
module: 'leads',
|
||||
});
|
||||
}
|
||||
return {
|
||||
...hub,
|
||||
cards,
|
||||
shortcuts: hub.shortcuts.filter(
|
||||
(item) =>
|
||||
(!item.admin || canSettings.value) && moduleAllowed(item.module),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const hubCanBack = computed(() => hubStack.value.length > 0);
|
||||
|
||||
function givenName(fullName: string): string {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return fullName.trim();
|
||||
@@ -177,12 +199,31 @@ watch([profileFullName, collapsed], () => {
|
||||
|
||||
function closeHub() {
|
||||
openHubKey.value = null;
|
||||
hubStack.value = [];
|
||||
}
|
||||
|
||||
function onHubClick(key: NavHubKey) {
|
||||
hubStack.value = [];
|
||||
openHubKey.value = openHubKey.value === key ? null : key;
|
||||
}
|
||||
|
||||
function openNestedHub(key: NavHubKey) {
|
||||
if (openHubKey.value) {
|
||||
hubStack.value = [...hubStack.value, openHubKey.value];
|
||||
}
|
||||
openHubKey.value = key;
|
||||
}
|
||||
|
||||
function backHub() {
|
||||
const prev = hubStack.value[hubStack.value.length - 1];
|
||||
if (!prev) {
|
||||
closeHub();
|
||||
return;
|
||||
}
|
||||
hubStack.value = hubStack.value.slice(0, -1);
|
||||
openHubKey.value = prev;
|
||||
}
|
||||
|
||||
function openTabHub(key: NavHubKey) {
|
||||
moreOpen.value = false;
|
||||
notifOpen.value = false;
|
||||
@@ -233,7 +274,11 @@ function closeNotif() {
|
||||
}
|
||||
|
||||
function onMobileMqChange(event: MediaQueryListEvent) {
|
||||
if (!event.matches) closeMore();
|
||||
isMobileNav.value = event.matches;
|
||||
if (!event.matches) {
|
||||
closeMore();
|
||||
hubStack.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
@@ -358,6 +403,7 @@ onMounted(() => {
|
||||
if (meta) profileNameObserver.observe(meta);
|
||||
}
|
||||
mobileMq = window.matchMedia('(max-width: 900px)');
|
||||
isMobileNav.value = mobileMq.matches;
|
||||
mobileMq.addEventListener('change', onMobileMqChange);
|
||||
});
|
||||
|
||||
@@ -717,7 +763,14 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="openHub" class="hub-layer">
|
||||
<button class="hub-dim" type="button" aria-label="بستن منو" @click="closeHub" />
|
||||
<NavHubPanel :hub="openHub" @close="closeHub" @go="goHub" />
|
||||
<NavHubPanel
|
||||
:hub="openHub"
|
||||
:can-back="hubCanBack"
|
||||
@close="closeHub"
|
||||
@back="backHub"
|
||||
@go="goHub"
|
||||
@open-hub="openNestedHub"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="mobile-tabbar mobile-only" aria-label="منوی اصلی">
|
||||
@@ -733,23 +786,21 @@ onUnmounted(() => {
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (customersActive || openHubKey === 'customers') }"
|
||||
:aria-expanded="openHubKey === 'customers'"
|
||||
:class="{
|
||||
on:
|
||||
!moreOpen &&
|
||||
(customersActive ||
|
||||
openHubKey === 'customers' ||
|
||||
(isMobileNav && openHubKey === 'leads')),
|
||||
}"
|
||||
:aria-expanded="
|
||||
openHubKey === 'customers' || (isMobileNav && openHubKey === 'leads')
|
||||
"
|
||||
@click="openTabHub('customers')"
|
||||
>
|
||||
<UiIcon name="users" :size="20" />
|
||||
<span>مشتریان</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
:class="{ on: !moreOpen && (leadsActive || openHubKey === 'leads') }"
|
||||
:aria-expanded="openHubKey === 'leads'"
|
||||
@click="openTabHub('leads')"
|
||||
>
|
||||
<UiIcon name="user-plus" :size="20" />
|
||||
<span>لیدها</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
|
||||
@@ -48,6 +48,8 @@ export type NavHubCard = {
|
||||
tint: NavHubTint;
|
||||
admin?: boolean;
|
||||
module?: OrgModuleKey;
|
||||
/** اگر ست باشد، بهجای مسیر، هاب تو در تو باز میشود (موبایل: مشتریان → لیدها) */
|
||||
hub?: NavHubKey;
|
||||
};
|
||||
|
||||
export type NavHubShortcut = {
|
||||
|
||||
@@ -500,6 +500,64 @@ function toggleAll(checked: boolean) {
|
||||
selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const LONG_PRESS_MOVE_PX = 12;
|
||||
let cardPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cardPressId: string | null = null;
|
||||
let cardPressTriggered = false;
|
||||
let cardPressX = 0;
|
||||
let cardPressY = 0;
|
||||
|
||||
function clearCardPress() {
|
||||
if (cardPressTimer) clearTimeout(cardPressTimer);
|
||||
cardPressTimer = null;
|
||||
cardPressId = null;
|
||||
}
|
||||
|
||||
function onCardPointerDown(leadId: string, event: PointerEvent) {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
clearCardPress();
|
||||
cardPressTriggered = false;
|
||||
cardPressId = leadId;
|
||||
cardPressX = event.clientX;
|
||||
cardPressY = event.clientY;
|
||||
cardPressTimer = setTimeout(() => {
|
||||
cardPressTriggered = true;
|
||||
cardPressTimer = null;
|
||||
toggleOne(leadId, !selected.value.has(leadId));
|
||||
try {
|
||||
navigator.vibrate?.(18);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function onCardPointerMove(event: PointerEvent) {
|
||||
if (!cardPressTimer || !cardPressId) return;
|
||||
const dx = event.clientX - cardPressX;
|
||||
const dy = event.clientY - cardPressY;
|
||||
if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) {
|
||||
clearCardPress();
|
||||
}
|
||||
}
|
||||
|
||||
function onCardPointerEnd() {
|
||||
clearCardPress();
|
||||
}
|
||||
|
||||
function onCardClick(lead: LeadDto) {
|
||||
if (cardPressTriggered) {
|
||||
cardPressTriggered = false;
|
||||
return;
|
||||
}
|
||||
if (selected.value.has(lead.id)) {
|
||||
toggleOne(lead.id, false);
|
||||
return;
|
||||
}
|
||||
goDetail(lead.id);
|
||||
}
|
||||
|
||||
function mergeFacets(items: LeadDto[]) {
|
||||
const cities = new Set(cityOptions.value);
|
||||
const sources = new Set(sourceOptions.value);
|
||||
@@ -1079,30 +1137,32 @@ onBeforeUnmount(() => {
|
||||
<UiIcon name="search" :size="16" />
|
||||
<input v-model="q" type="search" placeholder="جستجو..." />
|
||||
</form>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="cityFilter"
|
||||
@change="setCityFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه مناطق</option>
|
||||
<option v-for="c in cityOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="sourceFilter"
|
||||
@change="setSourceFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه منابع</option>
|
||||
<option v-for="s in sourceOptions" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="stageId"
|
||||
@change="setStageFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه وضعیت</option>
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
<div class="filter-selects">
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="cityFilter"
|
||||
@change="setCityFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه مناطق</option>
|
||||
<option v-for="c in cityOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="sourceFilter"
|
||||
@change="setSourceFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه منابع</option>
|
||||
<option v-for="s in sourceOptions" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<select
|
||||
class="filter-select"
|
||||
:value="stageId"
|
||||
@change="setStageFilter(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">همه وضعیت</option>
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-end">
|
||||
<div class="pop">
|
||||
@@ -1189,10 +1249,135 @@ onBeforeUnmount(() => {
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-else-if="data?.items.length">
|
||||
<div class="cards">
|
||||
<article
|
||||
v-for="(lead, idx) in data.items"
|
||||
:key="`m-${lead.id}`"
|
||||
class="m-card"
|
||||
:class="{ selected: selected.has(lead.id) }"
|
||||
:data-tint="stageTintForLead(lead)"
|
||||
@pointerdown="onCardPointerDown(lead.id, $event)"
|
||||
@pointermove="onCardPointerMove"
|
||||
@pointerup="onCardPointerEnd"
|
||||
@pointercancel="onCardPointerEnd"
|
||||
@contextmenu.prevent
|
||||
@click="onCardClick(lead)"
|
||||
>
|
||||
<div class="m-head">
|
||||
<span class="m-badge" :data-tint="stageTintForLead(lead)">
|
||||
{{ toFaDigits(rangeFrom + idx) }}
|
||||
</span>
|
||||
<UiDropdownMenu
|
||||
:open="logoMenuId === `m:${lead.id}`"
|
||||
align="start"
|
||||
@update:open="(v) => (logoMenuId = v ? `m:${lead.id}` : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="row-ico"
|
||||
:class="{ busy: logoBusyId === lead.id }"
|
||||
:data-tint="stageTintForLead(lead)"
|
||||
:title="lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<img
|
||||
v-if="logoUrls[lead.id]"
|
||||
:src="logoUrls[lead.id]"
|
||||
:alt="lead.title"
|
||||
class="row-logo"
|
||||
/>
|
||||
<UiIcon v-else :name="rowIcon(lead)" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" @click="openLogoPicker(lead.id)">
|
||||
{{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lead.hasLogo"
|
||||
type="button"
|
||||
class="danger-item"
|
||||
@click="removeLeadLogo(lead.id)"
|
||||
>
|
||||
حذف لوگو
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
<div class="m-title">
|
||||
<strong class="name">{{ lead.title }}</strong>
|
||||
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
|
||||
</div>
|
||||
<UiDropdownMenu
|
||||
:open="rowMenuId === `m:${lead.id}`"
|
||||
align="end"
|
||||
@update:open="(v) => (rowMenuId = v ? `m:${lead.id}` : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="act-btn"
|
||||
title="بیشتر"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" @click="goDetail(lead.id)">مشاهده</button>
|
||||
<button type="button" @click="openEdit(lead)">ویرایش</button>
|
||||
<a :href="`tel:${lead.mobile}`">تماس</a>
|
||||
<button type="button" class="danger-item" @click="deleteLead(lead)">
|
||||
حذف
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="m-meta-row">
|
||||
<button
|
||||
v-if="lead.mobile"
|
||||
type="button"
|
||||
class="m-line m-phone"
|
||||
dir="ltr"
|
||||
@click.stop="callLead(lead)"
|
||||
>
|
||||
<UiIcon name="phone" :size="13" />
|
||||
<span>{{ formatPhone(lead.mobile) }}</span>
|
||||
</button>
|
||||
<p v-if="lead.city || lead.region" class="m-line m-place">
|
||||
<UiIcon name="building" :size="13" />
|
||||
<span>{{ [lead.city, lead.region].filter(Boolean).join(' · ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="m-foot">
|
||||
<span v-if="lead.assigneeName" class="assignee">
|
||||
<span class="avatar">{{ initials(lead.assigneeName) }}</span>
|
||||
{{ lead.assigneeName }}
|
||||
</span>
|
||||
<span v-else class="muted-dash">بدون مسئول</span>
|
||||
<span class="m-activity">{{ lastActivity(lead).when }}</span>
|
||||
</div>
|
||||
|
||||
<div class="m-pills">
|
||||
<span class="stage-pill" :data-tint="stageTintForLead(lead)">
|
||||
{{ lead.stageName }}
|
||||
</span>
|
||||
<span v-if="lead.source" class="source-pill">{{ lead.source }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
@click.stop
|
||||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
<th class="num-col">#</th>
|
||||
<th class="icon-col"></th>
|
||||
<th>نام / شرکت</th>
|
||||
<th>شماره تماس</th>
|
||||
@@ -1202,23 +1387,23 @@ onBeforeUnmount(() => {
|
||||
<th>مسئول</th>
|
||||
<th>آخرین فعالیت</th>
|
||||
<th class="actions-col">اقدامات</th>
|
||||
<th class="check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
@click.stop
|
||||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="lead in data.items"
|
||||
v-for="(lead, idx) in data.items"
|
||||
:key="lead.id"
|
||||
class="row-click"
|
||||
@click="goDetail(lead.id)"
|
||||
>
|
||||
<td class="check-col" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(lead.id)"
|
||||
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td class="num-col">{{ toFaDigits(rangeFrom + idx) }}</td>
|
||||
<td class="icon-col" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="logoMenuId === lead.id"
|
||||
@@ -1287,9 +1472,6 @@ onBeforeUnmount(() => {
|
||||
<strong>{{ lastActivity(lead).label }}</strong>
|
||||
<span class="sub">{{ lastActivity(lead).when }}</span>
|
||||
</span>
|
||||
<span class="activity-ico">
|
||||
<UiIcon :name="lastActivity(lead).icon" :size="14" />
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-col" @click.stop>
|
||||
@@ -1334,13 +1516,6 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="check-col" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(lead.id)"
|
||||
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1764,22 +1939,85 @@ onBeforeUnmount(() => {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stats {
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
overflow: visible;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* جدا کردن جفت تا هر کارت یک سلول گرید شود */
|
||||
.stat-pair {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.stat-total,
|
||||
.stat-pair,
|
||||
.stat-cell.alone {
|
||||
flex: 1 1 calc(50% - 0.4rem);
|
||||
min-width: 9rem;
|
||||
.stat-cell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 5.5rem;
|
||||
min-height: 5.5rem;
|
||||
max-height: 5.5rem;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stat-total,
|
||||
.stat-pair,
|
||||
.stat-cell.alone {
|
||||
flex: 1 1 100%;
|
||||
.stat-total {
|
||||
padding: 0.65rem 0.75rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.stat-total-ico {
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
}
|
||||
|
||||
.stat-total .stat-label {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.stat-total .stat-num {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
padding: 1rem 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.stat-cell .stat-label {
|
||||
white-space: normal;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-cell .stat-num {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell {
|
||||
border: 1px solid #e5e7eb !important;
|
||||
border-radius: 12px !important;
|
||||
background: #fff;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell.wide {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell + .stat-cell {
|
||||
border-inline-start: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell:hover:not(:disabled),
|
||||
.stat-pair .stat-cell.on {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-soft) !important;
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,6 +2041,10 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-selects {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.filters-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1854,6 +2096,70 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filters-start {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters-label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-selects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-inline: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.filters-end {
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filters-end .sort-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1967,6 +2273,213 @@ onBeforeUnmount(() => {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: none;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.m-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.m-card:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.m-card.selected {
|
||||
background: #ecfdf5;
|
||||
border-color: #6ee7b7;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.m-card.selected:active {
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
.m-badge {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m-badge[data-tint='mint'],
|
||||
.m-badge[data-tint='teal'] {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
.m-badge[data-tint='amber'] {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.m-badge[data-tint='peach'] {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
.m-badge[data-tint='sky'] {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
.m-badge[data-tint='violet'] {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.m-badge[data-tint='rose'] {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.m-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.m-title .name {
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.m-title .sub {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.m-meta-row {
|
||||
display: flex;
|
||||
direction: ltr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-line {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-meta-row .m-place {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
direction: rtl;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m-meta-row .m-phone {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
button.m-line {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.m-phone {
|
||||
color: #059669;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m-phone:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.m-line :deep(svg) {
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.m-phone :deep(svg) {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.m-line span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-pills {
|
||||
display: flex;
|
||||
direction: ltr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.m-pills .stage-pill {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
}
|
||||
|
||||
.m-pills .source-pill {
|
||||
flex: 0 1 auto;
|
||||
max-width: 55%;
|
||||
margin-left: auto;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.m-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.m-activity {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -2001,6 +2514,14 @@ onBeforeUnmount(() => {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.num-col {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.check-col {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
@@ -2161,8 +2682,6 @@ onBeforeUnmount(() => {
|
||||
.activity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
justify-content: space-between;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
@@ -2171,18 +2690,6 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.activity-ico {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2216,6 +2723,16 @@ onBeforeUnmount(() => {
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.table-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ui-dd-panel) .danger-item,
|
||||
.danger-item {
|
||||
color: #b91c1c !important;
|
||||
@@ -2463,10 +2980,6 @@ onBeforeUnmount(() => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
:deep(.actions) .btn-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user