From ef58922e13b4b4a2ae7ecd1f490ac37075383ec1 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 8 Nov 2026 20:24:45 +0330 Subject: [PATCH] feat(leads): gather filters in one popover like activities Co-authored-by: Cursor --- apps/web/src/components/JalaliDateInput.vue | 2 +- apps/web/src/views/LeadsView.vue | 634 +++++++++++++------- 2 files changed, 433 insertions(+), 203 deletions(-) diff --git a/apps/web/src/components/JalaliDateInput.vue b/apps/web/src/components/JalaliDateInput.vue index e15287e..609568b 100644 --- a/apps/web/src/components/JalaliDateInput.vue +++ b/apps/web/src/components/JalaliDateInput.vue @@ -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); } diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue index 72b0e6e..249761a 100644 --- a/apps/web/src/views/LeadsView.vue +++ b/apps/web/src/views/LeadsView.vue @@ -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( + 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) { + filterSection.value = filterSection.value === name ? null : name; + filterQuery.value = ''; +} + +const filterAnchor = ref(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); }); @@ -1507,12 +1698,12 @@ onBeforeUnmount(() => { - + - - - - فیلترها - - - - - - - - - - - فیلترها - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - فقط تبدیل‌شده‌ها - - - - - فقط بایگانی‌شده‌ها - - - - - -