diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index c346ed1..ffa4310 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -246,7 +246,11 @@ export class LeadsController { @Query('source') source?: string, @Query('unassigned') unassigned?: string, @Query('archived') archived?: string, + @Query('converted') converted?: string, @Query('hasMobile') hasMobile?: string, + @Query('followUp') followUp?: string, + @Query('createdFrom') createdFrom?: string, + @Query('createdTo') createdTo?: string, ) { return this.leads.listIds(user, { q, @@ -258,7 +262,11 @@ export class LeadsController { source, unassigned: unassigned === '1' || unassigned === 'true', archived: archived === '1' || archived === 'true', + converted: converted === '1' || converted === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true', + followUp, + createdFrom, + createdTo, }); } @@ -276,8 +284,12 @@ export class LeadsController { @Query('source') source?: string, @Query('unassigned') unassigned?: string, @Query('archived') archived?: string, + @Query('converted') converted?: string, @Query('sort') sort?: string, @Query('hasMobile') hasMobile?: string, + @Query('followUp') followUp?: string, + @Query('createdFrom') createdFrom?: string, + @Query('createdTo') createdTo?: string, ) { return this.leads.list(user, { q, @@ -291,8 +303,12 @@ export class LeadsController { source, unassigned: unassigned === '1' || unassigned === 'true', archived: archived === '1' || archived === 'true', + converted: converted === '1' || converted === 'true', sort, hasMobile: hasMobile === '1' || hasMobile === 'true', + followUp, + createdFrom, + createdTo, }); } diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 29dcfc0..aa125cd 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service'; import { LeadStagesService } from './lead-stages.service'; import { buildSearchWhere, ci } from '../common/search'; import { GamificationService } from '../gamification/gamification.service'; +import { + addDays, + isDayKey, + tehranDayKey, + tehranDayStart, +} from '../routines/routine-dates'; const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_EXT: Record = { @@ -142,7 +148,11 @@ export class LeadsService { source?: string; unassigned?: boolean; archived?: boolean; + converted?: boolean; hasMobile?: boolean; + followUp?: string; + createdFrom?: string; + createdTo?: string; }, ): Prisma.LeadWhereInput { const search = buildSearchWhere(opts.q, (t) => [ @@ -161,10 +171,15 @@ export class LeadsService { if (opts.hasMobile) { and.push({ mobile: { not: '' } }); } + const follow = this.followUpWhere(opts.followUp); + if (follow) and.push(follow); + const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo); + if (createdRange) and.push(createdRange); return { organizationId: actor.organizationId, ...leadAccessWhere(actor), archivedAt: opts.archived ? { not: null } : null, + ...(opts.converted ? { convertedPersonId: { not: null } } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}), ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), ...(opts.unassigned ? { assigneeId: null } : {}), @@ -180,6 +195,52 @@ export class LeadsService { }; } + /** مرز روز به وقت تهران برای فیلترهای «امروز». */ + private tehranDayBounds(day = tehranDayKey()) { + const start = tehranDayStart(day); + const end = tehranDayStart(addDays(day, 1)); + return { start, end }; + } + + private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null { + if (!followUp) return null; + const { start, end } = this.tehranDayBounds(); + switch (followUp) { + case 'due_today': + return { nextFollowUpAt: { gte: start, lt: end } }; + case 'overdue': + return { nextFollowUpAt: { not: null, lt: start } }; + case 'called_today': + return { lastContactAt: { gte: start, lt: end } }; + case 'missed_today': + return { + nextFollowUpAt: { not: null, lt: end }, + OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }], + }; + case 'no_follow_up': + return { nextFollowUpAt: null }; + default: + return null; + } + } + + private createdAtWhere( + createdFrom?: string, + createdTo?: string, + ): Prisma.LeadWhereInput | null { + const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null; + const to = isDayKey(createdTo) + ? tehranDayStart(addDays(createdTo, 1)) + : null; + if (!from && !to) return null; + return { + createdAt: { + ...(from ? { gte: from } : {}), + ...(to ? { lt: to } : {}), + }, + }; + } + async list( actor: Actor, opts: { @@ -194,8 +255,12 @@ export class LeadsService { source?: string; unassigned?: boolean; archived?: boolean; + converted?: boolean; sort?: string; hasMobile?: boolean; + followUp?: string; + createdFrom?: string; + createdTo?: string; }, ): Promise> { await this.stages.ensureDefaults(actor.organizationId); @@ -243,7 +308,11 @@ export class LeadsService { source?: string; unassigned?: boolean; archived?: boolean; + converted?: boolean; hasMobile?: boolean; + followUp?: string; + createdFrom?: string; + createdTo?: string; }, max = FILTERED_IDS_MAX, ): Promise { diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue index 716a2a7..72b0e6e 100644 --- a/apps/web/src/views/LeadsView.vue +++ b/apps/web/src/views/LeadsView.vue @@ -5,10 +5,12 @@ 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, @@ -48,7 +50,6 @@ import { PlusOutlined, SearchOutlined, UnorderedListOutlined, - UserOutlined, } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; import { @@ -61,6 +62,7 @@ import { canManageSettings } from '@/lib/roles'; import { canAssignToOthers } from '@/lib/assign-access'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; +import JalaliDateInput from '@/components/JalaliDateInput.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import PhoneContactsImport from '@/components/PhoneContactsImport.vue'; import { @@ -92,8 +94,25 @@ const isMobile = computed(() => !screens.value.md); const PAGE_SIZE = 10; type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact'; +type FollowUpKey = + | '' + | 'due_today' + | 'overdue' + | 'called_today' + | 'missed_today' + | 'no_follow_up'; type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal'; +const FOLLOW_UP_OPTIONS: Array<{ value: FollowUpKey; label: string }> = [ + { value: '', label: 'همه پیگیری‌ها' }, + { value: 'due_today', label: 'قرار امروز' }, + { value: 'overdue', label: 'عقب‌افتاده' }, + { value: 'called_today', label: 'امروز تماس گرفته' }, + { value: 'missed_today', label: 'امروز باید می‌گرفت و نگرفته' }, + { value: 'no_follow_up', label: 'بدون پیگیری' }, +]; +const UNASSIGNED_VALUE = '__unassigned__'; + /** ردیف آمار صفحه لیدها — ترتیب و نام‌ها مطابق طرح */ const LEAD_STAT_DEFS: Array<{ label: string; @@ -162,8 +181,11 @@ const stages = ref([]); const stats = ref(null); const users = ref([]); const cityOptions = ref([]); +const regionOptions = ref([]); +const tradeOptions = ref([]); const sourceOptions = ref([]); const selected = ref>(new Set()); +const moreFiltersOpen = ref(false); const sortOpen = ref(false); const rowMenuId = ref(null); const logoMenuId = ref(null); @@ -235,12 +257,70 @@ const assigneeId = computed(() => const cityFilter = computed(() => typeof route.query.city === 'string' ? route.query.city : '', ); +const regionFilter = computed(() => + typeof route.query.region === 'string' ? route.query.region : '', +); +const tradeFilter = computed(() => + typeof route.query.trade === 'string' ? route.query.trade : '', +); const sourceFilter = computed(() => typeof route.query.source === 'string' ? route.query.source : '', ); +const followUpFilter = computed(() => { + const value = route.query.followUp; + if ( + value === 'due_today' || + value === 'overdue' || + value === 'called_today' || + value === 'missed_today' || + value === 'no_follow_up' + ) { + return value; + } + return ''; +}); +const createdFromFilter = computed(() => + typeof route.query.createdFrom === 'string' ? route.query.createdFrom : '', +); +const createdToFilter = computed(() => + typeof route.query.createdTo === 'string' ? route.query.createdTo : '', +); const unassigned = computed( () => route.query.unassigned === '1' || route.query.unassigned === 'true', ); +const archivedFilter = computed( + () => route.query.archived === '1' || route.query.archived === 'true', +); +const convertedFilter = computed( + () => route.query.converted === '1' || route.query.converted === 'true', +); +const assigneeSelectValue = computed(() => { + if (unassigned.value) return UNASSIGNED_VALUE; + return assigneeId.value || undefined; +}); +const moreFiltersCount = computed(() => + [ + cityFilter.value, + regionFilter.value, + tradeFilter.value, + sourceFilter.value, + createdFromFilter.value, + createdToFilter.value, + archivedFilter.value ? '1' : '', + 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 total = computed(() => data.value?.total ?? 0); const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))); @@ -514,14 +594,79 @@ function setStageFilter(id: string) { patchQuery({ stageId: id || undefined, page: undefined }); } +function setAssigneeFilter(value: string | undefined) { + if (value === UNASSIGNED_VALUE) { + patchQuery({ assigneeId: undefined, unassigned: '1', page: undefined }); + return; + } + patchQuery({ + assigneeId: value || undefined, + unassigned: undefined, + page: undefined, + }); +} + +function setFollowUpFilter(value: string | undefined) { + patchQuery({ + followUp: value || undefined, + page: undefined, + }); +} + function setCityFilter(value: string) { patchQuery({ city: value || undefined, page: undefined }); } +function setRegionFilter(value: string) { + patchQuery({ region: value || undefined, page: undefined }); +} + +function setTradeFilter(value: string) { + patchQuery({ trade: value || undefined, page: undefined }); +} + function setSourceFilter(value: string) { patchQuery({ source: value || undefined, page: undefined }); } +function setCreatedFromFilter(value: string) { + patchQuery({ createdFrom: value || undefined, page: undefined }); +} + +function setCreatedToFilter(value: string) { + patchQuery({ createdTo: value || undefined, page: undefined }); +} + +function setArchivedFilter(checked: boolean) { + patchQuery({ archived: checked ? '1' : undefined, page: undefined }); +} + +function setConvertedFilter(checked: boolean) { + patchQuery({ converted: checked ? '1' : undefined, page: undefined }); +} + +function clearMoreFilters() { + patchQuery({ + city: undefined, + region: undefined, + trade: undefined, + source: undefined, + createdFrom: undefined, + createdTo: undefined, + archived: undefined, + converted: undefined, + ...(isMobile.value + ? { + sort: undefined, + assigneeId: undefined, + unassigned: undefined, + followUp: undefined, + } + : {}), + page: undefined, + }); +} + function setPage(next: number) { if (next < 1 || next > pageCount.value) return; patchQuery({ page: next === 1 ? undefined : String(next) }); @@ -594,12 +739,18 @@ function onCardClick(lead: LeadDto) { function mergeFacets(items: LeadDto[]) { const cities = new Set(cityOptions.value); + const regions = new Set(regionOptions.value); + const trades = new Set(tradeOptions.value); const sources = new Set(sourceOptions.value); for (const lead of items) { if (lead.city?.trim()) cities.add(lead.city.trim()); + if (lead.region?.trim()) regions.add(lead.region.trim()); + if (lead.trade?.trim()) trades.add(lead.trade.trim()); if (lead.source?.trim()) sources.add(lead.source.trim()); } cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa')); + regionOptions.value = [...regions].sort((a, b) => a.localeCompare(b, 'fa')); + tradeOptions.value = [...trades].sort((a, b) => a.localeCompare(b, 'fa')); sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa')); } @@ -631,8 +782,15 @@ async function load() { stageId: stageId.value || undefined, assigneeId: assigneeId.value || undefined, city: cityFilter.value || undefined, + region: regionFilter.value || undefined, + trade: tradeFilter.value || undefined, source: sourceFilter.value || undefined, unassigned: unassigned.value ? '1' : undefined, + archived: archivedFilter.value ? '1' : undefined, + converted: convertedFilter.value ? '1' : undefined, + followUp: followUpFilter.value || undefined, + createdFrom: createdFromFilter.value || undefined, + createdTo: createdToFilter.value || undefined, }, }); data.value = res; @@ -1114,8 +1272,15 @@ watch( route.query.stageId, route.query.assigneeId, route.query.city, + route.query.region, + route.query.trade, route.query.source, route.query.unassigned, + route.query.archived, + route.query.converted, + route.query.followUp, + route.query.createdFrom, + route.query.createdTo, ], () => { q.value = typeof route.query.q === 'string' ? route.query.q : ''; @@ -1181,176 +1346,370 @@ onBeforeUnmount(() => {