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() { function liftAboveOverlays() {
let z = 120; 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); const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20); if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
} }
+432 -202
View File
@@ -5,12 +5,10 @@ import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Avatar as AAvatar, Avatar as AAvatar,
Badge as ABadge,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Checkbox as ACheckbox, Checkbox as ACheckbox,
Col as ACol, Col as ACol,
Drawer as ADrawer,
Dropdown as ADropdown, Dropdown as ADropdown,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
@@ -24,6 +22,7 @@ import {
Modal, Modal,
Modal as AModal, Modal as AModal,
Pagination as APagination, Pagination as APagination,
Popover as APopover,
RadioButton as ARadioButton, RadioButton as ARadioButton,
RadioGroup as ARadioGroup, RadioGroup as ARadioGroup,
Row as ARow, Row as ARow,
@@ -41,6 +40,7 @@ import type { TableColumnsType, UploadProps } from 'ant-design-vue';
import { import {
AppstoreOutlined, AppstoreOutlined,
DeleteOutlined, DeleteOutlined,
DownOutlined,
EditOutlined, EditOutlined,
ExportOutlined, ExportOutlined,
FileExcelOutlined, FileExcelOutlined,
@@ -55,6 +55,7 @@ import { api } from '@/lib/api';
import { import {
formatJalaliNumeric, formatJalaliNumeric,
formatRelativeContact, formatRelativeContact,
parseLocalInput,
toFaDigits, toFaDigits,
} from '@/lib/jalali'; } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -298,8 +299,12 @@ const assigneeSelectValue = computed(() => {
if (unassigned.value) return UNASSIGNED_VALUE; if (unassigned.value) return UNASSIGNED_VALUE;
return assigneeId.value || undefined; return assigneeId.value || undefined;
}); });
const moreFiltersCount = computed(() => /** شمارش فیلترهای داخل پاپ‌آور، مثل فعالیت‌ها. */
const mobileFilterBadgeCount = computed(() =>
[ [
stageId.value,
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
cityFilter.value, cityFilter.value,
regionFilter.value, regionFilter.value,
tradeFilter.value, tradeFilter.value,
@@ -310,17 +315,204 @@ const moreFiltersCount = computed(() =>
convertedFilter.value ? '1' : '', convertedFilter.value ? '1' : '',
].filter(Boolean).length, ].filter(Boolean).length,
); );
/** در موبایل مرتب‌سازی و مسئول و پیگیری هم داخل کشو هستند. */ const filterBtnLabel = computed(() =>
const mobileFilterBadgeCount = computed(() => { mobileFilterBadgeCount.value
const extra = isMobile.value ? `فیلتر (${toFaDigits(mobileFilterBadgeCount.value)})`
? [ : 'فیلتر',
sort.value !== 'updated' ? '1' : '', );
assigneeSelectValue.value ? '1' : '',
followUpFilter.value, type LeadFacetKey = 'assignee' | 'stage' | 'source' | 'followUp' | 'city' | 'region' | 'trade';
].filter(Boolean).length const filterSection = ref<LeadFacetKey | 'createdFrom' | 'createdTo' | 'converted' | 'archived' | null>(
: 0; null,
return moreFiltersCount.value + extra; );
}); 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 total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))); const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
@@ -647,6 +839,10 @@ function setConvertedFilter(checked: boolean) {
function clearMoreFilters() { function clearMoreFilters() {
patchQuery({ patchQuery({
stageId: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
city: undefined, city: undefined,
region: undefined, region: undefined,
trade: undefined, trade: undefined,
@@ -655,16 +851,9 @@ function clearMoreFilters() {
createdTo: undefined, createdTo: undefined,
archived: undefined, archived: undefined,
converted: undefined, converted: undefined,
...(isMobile.value
? {
sort: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
}
: {}),
page: undefined, page: undefined,
}); });
filterSection.value = null;
} }
function setPage(next: number) { function setPage(next: number) {
@@ -1342,6 +1531,8 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopImportPoll(); stopImportPoll();
revokeLogoUrls(); revokeLogoUrls();
window.removeEventListener('resize', placeFilterPopover);
window.removeEventListener('scroll', placeFilterPopover, true);
}); });
</script> </script>
@@ -1507,12 +1698,12 @@ onBeforeUnmount(() => {
</a-space> </a-space>
</a-flex> </a-flex>
<a-flex v-if="isMobile" vertical :gap="8"> <a-flex :vertical="isMobile" align="center" wrap="wrap" :gap="8">
<a-input-search <a-input-search
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو نام، موبایل، شهر…" placeholder="جستجو نام، موبایل، شهر…"
style="width: 100%" :style="isMobile ? { width: '100%' } : { width: '15rem', maxWidth: '100%' }"
@search="onSearch" @search="onSearch"
> >
<template #enterButton> <template #enterButton>
@@ -1521,195 +1712,234 @@ onBeforeUnmount(() => {
</a-button> </a-button>
</template> </template>
</a-input-search> </a-input-search>
<a-badge <div ref="filterAnchor" :style="isMobile ? { width: '100%' } : undefined">
:count="mobileFilterBadgeCount" <a-popover
:offset="[-2, 2]" :open="moreFiltersOpen"
:style="{ width: '100%', display: 'block' }" trigger="click"
> placement="bottomRight"
<a-button block @click="moreFiltersOpen = true"> :arrow="false"
<template #icon><FilterOutlined /></template> :auto-adjust-overflow="false"
فیلترها :overlay-inner-style="{
</a-button> width: 'min(20rem, calc(100vw - 1.5rem))',
</a-badge> maxHeight: filterPopoverMaxHeight,
</a-flex> overflow: 'auto',
<a-flex v-else align="center" wrap="wrap" :gap="8"> }"
<a-input-search @open-change="onFilterPopover"
v-model:value="q" >
allow-clear <a-button
placeholder="جستجو نام، موبایل، شهر…" :block="isMobile"
style="width: 15rem; max-width: 100%" :style="mobileFilterBadgeCount
@search="onSearch" ? {
> borderColor: token.colorPrimary,
<template #enterButton> color: token.colorPrimary,
<a-button type="primary"> background: token.colorPrimaryBg,
<template #icon><SearchOutlined /></template> }
: undefined"
>
<a-flex align="center" justify="center" :gap="6">
<FilterOutlined />
<span>{{ filterBtnLabel }}</span>
<DownOutlined />
</a-flex>
</a-button> </a-button>
</template> <template #content>
</a-input-search> <a-flex vertical :gap="10" @click.stop>
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]"> <div v-for="facet in textFacets" :key="facet.key">
<a-button @click="moreFiltersOpen = true"> <div
<template #icon><FilterOutlined /></template> role="button"
فیلترها tabindex="0"
</a-button> :style="{ cursor: 'pointer' }"
</a-badge> @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 <a-select
:value="sort" :value="sort"
style="width: 10rem" :style="isMobile ? { width: '100%' } : { width: '10rem' }"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))" :options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)" @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-flex> </a-flex>
</a-card> </a-card>
</a-col> </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-col v-if="selectedIds.length" :span="24" @click.stop>
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }"> <a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
<a-space :size="8" wrap> <a-space :size="8" wrap>