feat(leads): gather filters in one popover like activities

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 20:24:45 +03:30
co-authored by Cursor
parent 06036b00d3
commit ef58922e13
2 changed files with 433 additions and 203 deletions
+1 -1
View File
@@ -360,7 +360,7 @@ function place() {
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
+432 -202
View File
@@ -5,12 +5,10 @@ import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Badge as ABadge,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Drawer as ADrawer,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
@@ -24,6 +22,7 @@ import {
Modal,
Modal as AModal,
Pagination as APagination,
Popover as APopover,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Row as ARow,
@@ -41,6 +40,7 @@ import type { TableColumnsType, UploadProps } from 'ant-design-vue';
import {
AppstoreOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
ExportOutlined,
FileExcelOutlined,
@@ -55,6 +55,7 @@ import { api } from '@/lib/api';
import {
formatJalaliNumeric,
formatRelativeContact,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
@@ -298,8 +299,12 @@ const assigneeSelectValue = computed(() => {
if (unassigned.value) return UNASSIGNED_VALUE;
return assigneeId.value || undefined;
});
const moreFiltersCount = computed(() =>
/** شمارش فیلترهای داخل پاپ‌آور، مثل فعالیت‌ها. */
const mobileFilterBadgeCount = computed(() =>
[
stageId.value,
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
cityFilter.value,
regionFilter.value,
tradeFilter.value,
@@ -310,17 +315,204 @@ const moreFiltersCount = computed(() =>
convertedFilter.value ? '1' : '',
].filter(Boolean).length,
);
/** در موبایل مرتب‌سازی و مسئول و پیگیری هم داخل کشو هستند. */
const mobileFilterBadgeCount = computed(() => {
const extra = isMobile.value
? [
sort.value !== 'updated' ? '1' : '',
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
].filter(Boolean).length
: 0;
return moreFiltersCount.value + extra;
});
const filterBtnLabel = computed(() =>
mobileFilterBadgeCount.value
? `فیلتر (${toFaDigits(mobileFilterBadgeCount.value)})`
: 'فیلتر',
);
type LeadFacetKey = 'assignee' | 'stage' | 'source' | 'followUp' | 'city' | 'region' | 'trade';
const filterSection = ref<LeadFacetKey | 'createdFrom' | 'createdTo' | 'converted' | 'archived' | null>(
null,
);
const filterQuery = ref('');
const FACET_LIST_CAP = 40;
function normFilter(value: string) {
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
}
function toggleFilterSection(name: NonNullable<typeof filterSection.value>) {
filterSection.value = filterSection.value === name ? null : name;
filterQuery.value = '';
}
const filterAnchor = ref<HTMLElement | null>(null);
const filterPopoverMaxHeight = ref('min(70vh, 32rem)');
function placeFilterPopover() {
const el = filterAnchor.value;
if (!el) return;
const space = Math.floor(window.innerHeight - el.getBoundingClientRect().bottom - 12);
filterPopoverMaxHeight.value = `${Math.max(220, space)}px`;
}
function onFilterPopover(open: boolean) {
if (!open && document.getElementById('jalali-picker')) return;
if (open) placeFilterPopover();
moreFiltersOpen.value = open;
if (open) {
window.addEventListener('resize', placeFilterPopover);
window.addEventListener('scroll', placeFilterPopover, true);
} else {
filterSection.value = null;
window.removeEventListener('resize', placeFilterPopover);
window.removeEventListener('scroll', placeFilterPopover, true);
}
}
function dateFacetLabel(value: string) {
if (!value) return 'همه';
const parsed = parseLocalInput(value);
return parsed ? formatJalaliNumeric(parsed) : value;
}
function facetValueStyle(active: boolean) {
return {
color: active ? token.value.colorPrimary : token.value.colorTextQuaternary,
fontWeight: active ? 700 : 400,
maxWidth: '9.5rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const;
}
const textFacets = computed(() => [
{
key: 'assignee' as const,
label: 'کارشناس',
value: unassigned.value
? 'بدون کارشناس'
: users.value.find((user) => user.id === assigneeId.value)?.fullName || 'همه',
active: Boolean(assigneeSelectValue.value),
current: assigneeSelectValue.value || '',
searchable: true,
options: [
{ value: '', label: 'همه' },
{ value: UNASSIGNED_VALUE, label: 'بدون کارشناس' },
...users.value.map((user) => ({ value: user.id, label: user.fullName })),
],
},
{
key: 'stage' as const,
label: 'مرحله',
value: stages.value.find((stage) => stage.id === stageId.value)?.name || 'همه',
active: Boolean(stageId.value),
current: stageId.value,
searchable: stages.value.length > 8,
options: [
{ value: '', label: 'همه' },
...stages.value.map((stage) => ({ value: stage.id, label: stage.name })),
],
},
{
key: 'source' as const,
label: 'منبع',
value: sourceFilter.value || 'همه',
active: Boolean(sourceFilter.value),
current: sourceFilter.value,
searchable: sourceOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...sourceOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'followUp' as const,
label: 'پیگیری',
value:
FOLLOW_UP_OPTIONS.find((item) => item.value === followUpFilter.value && item.value)?.label ||
'همه',
active: Boolean(followUpFilter.value),
current: followUpFilter.value,
searchable: false,
options: [
{ value: '', label: 'همه' },
...FOLLOW_UP_OPTIONS.filter((item) => item.value).map((item) => ({
value: item.value,
label: item.label,
})),
],
},
{
key: 'city' as const,
label: 'شهر',
value: cityFilter.value || 'همه',
active: Boolean(cityFilter.value),
current: cityFilter.value,
searchable: cityOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...cityOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'region' as const,
label: 'منطقه',
value: regionFilter.value || 'همه',
active: Boolean(regionFilter.value),
current: regionFilter.value,
searchable: regionOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...regionOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'trade' as const,
label: 'صنف',
value: tradeFilter.value || 'همه',
active: Boolean(tradeFilter.value),
current: tradeFilter.value,
searchable: tradeOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...tradeOptions.value.map((item) => ({ value: item, label: item })),
],
},
]);
function visibleFacetOptions(options: Array<{ value: string; label: string }>) {
const query = normFilter(filterQuery.value);
const rest = options.filter(
(item) => item.value !== '' && (!query || normFilter(item.label).includes(query)),
);
return {
options: [...options.filter((item) => item.value === ''), ...rest.slice(0, FACET_LIST_CAP)],
hidden: Math.max(0, rest.length - FACET_LIST_CAP),
empty: Boolean(query) && rest.length === 0,
};
}
function pickFacet(key: LeadFacetKey, value: string) {
const facet = textFacets.value.find((item) => item.key === key);
const next = facet && facet.current === value ? '' : value;
if (key === 'assignee') setAssigneeFilter(next || undefined);
else if (key === 'stage') setStageFilter(next);
else if (key === 'source') setSourceFilter(next);
else if (key === 'followUp') setFollowUpFilter(next || undefined);
else if (key === 'city') setCityFilter(next);
else if (key === 'region') setRegionFilter(next);
else if (key === 'trade') setTradeFilter(next);
filterSection.value = null;
}
function pickFlag(which: 'converted' | 'archived', on: boolean) {
const current = which === 'converted' ? convertedFilter.value : archivedFilter.value;
const next = on ? !current : false;
if (which === 'converted') setConvertedFilter(next);
else setArchivedFilter(next);
filterSection.value = null;
}
function facetOptionStyle(selected: boolean) {
return {
textAlign: 'start' as const,
background: selected ? token.value.colorPrimaryBg : 'transparent',
color: selected ? token.value.colorPrimary : token.value.colorText,
};
}
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
@@ -647,6 +839,10 @@ function setConvertedFilter(checked: boolean) {
function clearMoreFilters() {
patchQuery({
stageId: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
city: undefined,
region: undefined,
trade: undefined,
@@ -655,16 +851,9 @@ function clearMoreFilters() {
createdTo: undefined,
archived: undefined,
converted: undefined,
...(isMobile.value
? {
sort: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
}
: {}),
page: undefined,
});
filterSection.value = null;
}
function setPage(next: number) {
@@ -1342,6 +1531,8 @@ onMounted(async () => {
onBeforeUnmount(() => {
stopImportPoll();
revokeLogoUrls();
window.removeEventListener('resize', placeFilterPopover);
window.removeEventListener('scroll', placeFilterPopover, true);
});
</script>
@@ -1507,12 +1698,12 @@ onBeforeUnmount(() => {
</a-space>
</a-flex>
<a-flex v-if="isMobile" vertical :gap="8">
<a-flex :vertical="isMobile" align="center" wrap="wrap" :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
style="width: 100%"
:style="isMobile ? { width: '100%' } : { width: '15rem', maxWidth: '100%' }"
@search="onSearch"
>
<template #enterButton>
@@ -1521,195 +1712,234 @@ onBeforeUnmount(() => {
</a-button>
</template>
</a-input-search>
<a-badge
:count="mobileFilterBadgeCount"
:offset="[-2, 2]"
:style="{ width: '100%', display: 'block' }"
>
<a-button block @click="moreFiltersOpen = true">
<template #icon><FilterOutlined /></template>
فیلترها
</a-button>
</a-badge>
</a-flex>
<a-flex v-else align="center" wrap="wrap" :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
style="width: 15rem; max-width: 100%"
@search="onSearch"
>
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
<div ref="filterAnchor" :style="isMobile ? { width: '100%' } : undefined">
<a-popover
:open="moreFiltersOpen"
trigger="click"
placement="bottomRight"
:arrow="false"
:auto-adjust-overflow="false"
:overlay-inner-style="{
width: 'min(20rem, calc(100vw - 1.5rem))',
maxHeight: filterPopoverMaxHeight,
overflow: 'auto',
}"
@open-change="onFilterPopover"
>
<a-button
:block="isMobile"
:style="mobileFilterBadgeCount
? {
borderColor: token.colorPrimary,
color: token.colorPrimary,
background: token.colorPrimaryBg,
}
: undefined"
>
<a-flex align="center" justify="center" :gap="6">
<FilterOutlined />
<span>{{ filterBtnLabel }}</span>
<DownOutlined />
</a-flex>
</a-button>
</template>
</a-input-search>
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]">
<a-button @click="moreFiltersOpen = true">
<template #icon><FilterOutlined /></template>
فیلترها
</a-button>
</a-badge>
<template #content>
<a-flex vertical :gap="10" @click.stop>
<div v-for="facet in textFacets" :key="facet.key">
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection(facet.key)"
@keydown.enter.prevent="toggleFilterSection(facet.key)"
>
<a-flex justify="space-between" align="center">
<span>{{ facet.label }}</span>
<a-typography-text :style="facetValueStyle(facet.active)">
{{ facet.value }}
</a-typography-text>
</a-flex>
</div>
<a-flex
v-if="filterSection === facet.key"
vertical
:gap="4"
:style="{ marginTop: '6px' }"
>
<a-input
v-if="facet.searchable"
v-model:value="filterQuery"
allow-clear
placeholder="جستجو"
/>
<div :style="{ maxHeight: '11.5rem', overflow: 'auto' }">
<a-button
v-for="option in visibleFacetOptions(facet.options).options"
:key="option.value || 'all'"
type="text"
block
:style="facetOptionStyle(facet.current === option.value)"
@click="pickFacet(facet.key, option.value)"
>
{{ option.label }}
</a-button>
<a-typography-text
v-if="visibleFacetOptions(facet.options).empty"
type="secondary"
>
موردی پیدا نشد
</a-typography-text>
</div>
<a-typography-text
v-if="visibleFacetOptions(facet.options).hidden"
type="secondary"
>
برای دیدن بقیه، نام را دقیق‌تر بنویسید
</a-typography-text>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdFrom')"
@keydown.enter.prevent="toggleFilterSection('createdFrom')"
>
<a-flex justify="space-between" align="center">
<span>از تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdFromFilter))">
{{ dateFacetLabel(createdFromFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdFrom'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdFromFilter"
placeholder="از تاریخ"
@update:model-value="(value: string) => { setCreatedFromFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdTo')"
@keydown.enter.prevent="toggleFilterSection('createdTo')"
>
<a-flex justify="space-between" align="center">
<span>تا تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdToFilter))">
{{ dateFacetLabel(createdToFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdTo'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdToFilter"
placeholder="تا تاریخ"
@update:model-value="(value: string) => { setCreatedToFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('converted')"
@keydown.enter.prevent="toggleFilterSection('converted')"
>
<a-flex justify="space-between" align="center">
<span>تبدیل‌شده</span>
<a-typography-text :style="facetValueStyle(convertedFilter)">
{{ convertedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'converted'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!convertedFilter)"
@click="pickFlag('converted', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(convertedFilter)"
@click="pickFlag('converted', true)"
>
فقط تبدیل‌شده‌ها
</a-button>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('archived')"
@keydown.enter.prevent="toggleFilterSection('archived')"
>
<a-flex justify="space-between" align="center">
<span>بایگانی</span>
<a-typography-text :style="facetValueStyle(archivedFilter)">
{{ archivedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'archived'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!archivedFilter)"
@click="pickFlag('archived', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(archivedFilter)"
@click="pickFlag('archived', true)"
>
فقط بایگانی‌شده‌ها
</a-button>
</a-flex>
</div>
<a-button
v-if="mobileFilterBadgeCount"
type="text"
danger
block
@click="clearMoreFilters"
>
پاک کردن فیلترها
</a-button>
</a-flex>
</template>
</a-popover>
</div>
<a-select
:value="sort"
style="width: 10rem"
:style="isMobile ? { width: '100%' } : { width: '10rem' }"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
<a-select
:value="assigneeSelectValue"
allow-clear
show-search
option-filter-prop="label"
placeholder="مسئول"
style="width: 10rem"
:options="[
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
...users.map((u) => ({ value: u.id, label: u.fullName })),
]"
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
/>
<a-select
:value="followUpFilter || undefined"
allow-clear
placeholder="پیگیری"
style="width: 13rem"
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
/>
</a-flex>
</a-flex>
</a-card>
</a-col>
<a-drawer
v-model:open="moreFiltersOpen"
:title="isMobile ? 'فیلترها' : 'فیلترهای بیشتر'"
placement="left"
:width="isMobile ? '100%' : 380"
:destroy-on-close="false"
>
<a-form layout="vertical">
<template v-if="isMobile">
<a-form-item label="مرتب‌سازی">
<a-select
:value="sort"
style="width: 100%"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
</a-form-item>
<a-form-item label="مسئول">
<a-select
:value="assigneeSelectValue"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه مسئولان"
style="width: 100%"
:options="[
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
...users.map((u) => ({ value: u.id, label: u.fullName })),
]"
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
/>
</a-form-item>
<a-form-item label="پیگیری">
<a-select
:value="followUpFilter || undefined"
allow-clear
placeholder="همه پیگیری‌ها"
style="width: 100%"
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
/>
</a-form-item>
</template>
<a-form-item label="شهر">
<a-select
:value="cityFilter || undefined"
allow-clear
show-search
placeholder="همه شهرها"
:options="cityOptions.map((c) => ({ value: c, label: c }))"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="منطقه">
<a-select
:value="regionFilter || undefined"
allow-clear
show-search
placeholder="همه مناطق"
:options="regionOptions.map((r) => ({ value: r, label: r }))"
@update:value="(v) => setRegionFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="صنف">
<a-select
:value="tradeFilter || undefined"
allow-clear
show-search
placeholder="همه اصناف"
:options="tradeOptions.map((t) => ({ value: t, label: t }))"
@update:value="(v) => setTradeFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="منبع">
<a-select
:value="sourceFilter || undefined"
allow-clear
show-search
placeholder="همه منابع"
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="از تاریخ ثبت">
<JalaliDateInput
:model-value="createdFromFilter"
placeholder="از تاریخ"
@update:model-value="setCreatedFromFilter"
/>
</a-form-item>
<a-form-item label="تا تاریخ ثبت">
<JalaliDateInput
:model-value="createdToFilter"
placeholder="تا تاریخ"
@update:model-value="setCreatedToFilter"
/>
</a-form-item>
<a-form-item>
<a-checkbox
:checked="convertedFilter"
@update:checked="setConvertedFilter"
>
فقط تبدیل‌شده‌ها
</a-checkbox>
</a-form-item>
<a-form-item>
<a-checkbox
:checked="archivedFilter"
@update:checked="setArchivedFilter"
>
فقط بایگانی‌شده‌ها
</a-checkbox>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="!mobileFilterBadgeCount" @click="clearMoreFilters">
پاک کردن
</a-button>
<a-button type="primary" @click="moreFiltersOpen = false">
بستن
</a-button>
</a-space>
</template>
</a-drawer>
<a-col v-if="selectedIds.length" :span="24" @click.stop>
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
<a-space :size="8" wrap>