feat(leads): expand filters and tidy the list chrome on mobile

Owners can filter by assignee and follow-up status, with extra filters in a drawer, while mobile keeps a compact search-and-filter bar without the stage chip grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-07 11:07:34 +03:30
co-authored by Cursor
parent 0ca9540524
commit 21d2c69227
3 changed files with 580 additions and 136 deletions
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -258,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -291,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
+69
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,10 +171,15 @@ export class LeadsService {
if (opts.hasMobile) { if (opts.hasMobile) {
and.push({ mobile: { not: '' } }); 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 { return {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...leadAccessWhere(actor), ...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null, archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), ...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}), ...(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( async list(
actor: Actor, actor: Actor,
opts: { opts: {
@@ -194,8 +255,12 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
sort?: string; sort?: string;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Promise<PaginatedResponse<LeadDto>> { ): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId); await this.stages.ensureDefaults(actor.organizationId);
@@ -243,7 +308,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
+495 -136
View File
@@ -5,10 +5,12 @@ 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,
@@ -48,7 +50,6 @@ import {
PlusOutlined, PlusOutlined,
SearchOutlined, SearchOutlined,
UnorderedListOutlined, UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { import {
@@ -61,6 +62,7 @@ import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access'; import { canAssignToOthers } from '@/lib/assign-access';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue'; import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import { import {
@@ -92,8 +94,25 @@ const isMobile = computed(() => !screens.value.md);
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact'; 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'; 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<{ const LEAD_STAT_DEFS: Array<{
label: string; label: string;
@@ -162,8 +181,11 @@ const stages = ref<LeadStageDto[]>([]);
const stats = ref<LeadStatsDto | null>(null); const stats = ref<LeadStatsDto | null>(null);
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
const cityOptions = ref<string[]>([]); const cityOptions = ref<string[]>([]);
const regionOptions = ref<string[]>([]);
const tradeOptions = ref<string[]>([]);
const sourceOptions = ref<string[]>([]); const sourceOptions = ref<string[]>([]);
const selected = ref<Set<string>>(new Set()); const selected = ref<Set<string>>(new Set());
const moreFiltersOpen = ref(false);
const sortOpen = ref(false); const sortOpen = ref(false);
const rowMenuId = ref<string | null>(null); const rowMenuId = ref<string | null>(null);
const logoMenuId = ref<string | null>(null); const logoMenuId = ref<string | null>(null);
@@ -235,12 +257,70 @@ const assigneeId = computed(() =>
const cityFilter = computed(() => const cityFilter = computed(() =>
typeof route.query.city === 'string' ? route.query.city : '', 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(() => const sourceFilter = computed(() =>
typeof route.query.source === 'string' ? route.query.source : '', typeof route.query.source === 'string' ? route.query.source : '',
); );
const followUpFilter = computed<FollowUpKey>(() => {
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( const unassigned = computed(
() => route.query.unassigned === '1' || route.query.unassigned === 'true', () => 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 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)));
@@ -514,14 +594,79 @@ function setStageFilter(id: string) {
patchQuery({ stageId: id || undefined, page: undefined }); 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) { function setCityFilter(value: string) {
patchQuery({ city: value || undefined, page: undefined }); 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) { function setSourceFilter(value: string) {
patchQuery({ source: value || undefined, page: undefined }); 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) { function setPage(next: number) {
if (next < 1 || next > pageCount.value) return; if (next < 1 || next > pageCount.value) return;
patchQuery({ page: next === 1 ? undefined : String(next) }); patchQuery({ page: next === 1 ? undefined : String(next) });
@@ -594,12 +739,18 @@ function onCardClick(lead: LeadDto) {
function mergeFacets(items: LeadDto[]) { function mergeFacets(items: LeadDto[]) {
const cities = new Set(cityOptions.value); const cities = new Set(cityOptions.value);
const regions = new Set(regionOptions.value);
const trades = new Set(tradeOptions.value);
const sources = new Set(sourceOptions.value); const sources = new Set(sourceOptions.value);
for (const lead of items) { for (const lead of items) {
if (lead.city?.trim()) cities.add(lead.city.trim()); 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()); if (lead.source?.trim()) sources.add(lead.source.trim());
} }
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa')); 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')); sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
} }
@@ -631,8 +782,15 @@ async function load() {
stageId: stageId.value || undefined, stageId: stageId.value || undefined,
assigneeId: assigneeId.value || undefined, assigneeId: assigneeId.value || undefined,
city: cityFilter.value || undefined, city: cityFilter.value || undefined,
region: regionFilter.value || undefined,
trade: tradeFilter.value || undefined,
source: sourceFilter.value || undefined, source: sourceFilter.value || undefined,
unassigned: unassigned.value ? '1' : 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; data.value = res;
@@ -1114,8 +1272,15 @@ watch(
route.query.stageId, route.query.stageId,
route.query.assigneeId, route.query.assigneeId,
route.query.city, route.query.city,
route.query.region,
route.query.trade,
route.query.source, route.query.source,
route.query.unassigned, 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 : ''; q.value = typeof route.query.q === 'string' ? route.query.q : '';
@@ -1181,176 +1346,370 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<a-row :gutter="[24, 24]" @click="closePopups"> <a-row :gutter="[16, 16]" @click="closePopups">
<a-col :span="24"> <a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM"> <a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<div> <a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
<a-typography-text type="secondary">
مدیریت و پیگیری تمام لیدهای ورودی
</a-typography-text>
</div>
<a-space :size="8" wrap> <a-space :size="8" wrap>
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel"> <template v-if="isMobile">
<template #icon><ExportOutlined /></template> <a-dropdown :trigger="['click']" placement="bottomLeft">
خروجی اکسل <a-button :disabled="busy">
</a-button> <template #icon><MoreOutlined /></template>
<ExcelImportFlow بیشتر
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-button :disabled="busy" :loading="excelLoading">
<template #icon><FileExcelOutlined /></template>
ورود از اکسل
</a-button> </a-button>
</template> <template #overlay>
</ExcelImportFlow> <a-menu>
<PhoneContactsImport <a-menu-item
target="leads" key="export"
:disabled="busy" :disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
@start="onPhoneImportStart" @click="exportExcel"
@done="onPhoneImportDone" >
@fail="onPhoneImportFail" <ExportOutlined />
@settled="onPhoneImportSettled" خروجی اکسل
> </a-menu-item>
<a-button :disabled="busy"> <a-menu-item key="excel" :disabled="busy">
<template #icon><PhoneOutlined /></template> <ExcelImportFlow
از مخاطبین گوشی preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-flex align="center" :gap="8" :style="{ opacity: excelLoading ? 0.6 : 1 }">
<FileExcelOutlined />
ورود از اکسل
</a-flex>
</template>
</ExcelImportFlow>
</a-menu-item>
<a-menu-item key="phone" :disabled="busy">
<PhoneContactsImport
target="leads"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<a-flex align="center" :gap="8">
<PhoneOutlined />
از مخاطبین گوشی
</a-flex>
</PhoneContactsImport>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template v-else>
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<template #icon><ExportOutlined /></template>
خروجی اکسل
</a-button> </a-button>
</PhoneContactsImport> <ExcelImportFlow
preview-path="/leads/import/preview"
import-path="/leads/import/file"
:disabled="busy"
@start="onExcelImportStart"
@done="onExcelImportDone"
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
<template #default="{ loading: excelLoading }">
<a-button :disabled="busy" :loading="excelLoading">
<template #icon><FileExcelOutlined /></template>
ورود از اکسل
</a-button>
</template>
</ExcelImportFlow>
<PhoneContactsImport
target="leads"
:disabled="busy"
@start="onPhoneImportStart"
@done="onPhoneImportDone"
@fail="onPhoneImportFail"
@settled="onPhoneImportSettled"
>
<a-button :disabled="busy">
<template #icon><PhoneOutlined /></template>
از مخاطبین گوشی
</a-button>
</PhoneContactsImport>
</template>
<a-radio-group
v-if="isMobile"
value="list"
button-style="solid"
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
>
<a-radio-button value="list">
<UnorderedListOutlined />
</a-radio-button>
<a-radio-button value="board">
<AppstoreOutlined />
</a-radio-button>
</a-radio-group>
<a-button type="primary" @click.stop="openCreate"> <a-button type="primary" @click.stop="openCreate">
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
لید جدید {{ isMobile ? 'جدید' : 'لید جدید' }}
</a-button> </a-button>
</a-space> </a-space>
</a-flex> </a-flex>
</a-col> </a-col>
<a-col :span="24">
<a-row :gutter="[12, 12]">
<a-col :xs="12" :sm="8" :md="4">
<a-card
:bordered="true"
hoverable
:style="{
borderColor: !stageId ? token.colorPrimary : undefined,
cursor: 'pointer',
}"
@click="setStageFilter('')"
>
<a-flex align="center" :gap="12">
<a-avatar :style="{ background: token.colorPrimaryBg, color: token.colorPrimary }">
<template #icon><UserOutlined /></template>
</a-avatar>
<div>
<a-typography-text type="secondary">همه لیدها</a-typography-text>
<div>
<a-typography-text strong>{{ formatCount(totalLeadsCount) }}</a-typography-text>
</div>
</div>
</a-flex>
</a-card>
</a-col>
<a-col
v-for="card in stageCards"
:key="card.key"
:xs="12"
:sm="8"
:md="4"
>
<a-card
:bordered="true"
hoverable
:style="{
borderColor: isStatActive(card) ? token.colorPrimary : undefined,
cursor: card.stageId ? 'pointer' : 'not-allowed',
opacity: card.stageId ? 1 : 0.55,
}"
@click="card.stageId && selectStatCard(card)"
>
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
<div>
<a-typography-text strong>{{ formatCount(card.count) }}</a-typography-text>
</div>
</a-card>
</a-col>
</a-row>
</a-col>
<a-col :span="24" @click.stop> <a-col :span="24" @click.stop>
<a-card :bordered="true"> <a-card :bordered="true">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12"> <a-flex vertical :gap="12">
<a-flex align="center" wrap="wrap" :gap="8" flex="1 1 auto"> <a-flex v-if="!isMobile" justify="space-between" align="center" wrap="wrap" :gap="8">
<a-flex wrap="wrap" :gap="8" align="center" flex="1 1 auto">
<a-button :type="!stageId ? 'primary' : 'default'" @click="setStageFilter('')">
همه لیدها
<a-typography-text
:type="!stageId ? undefined : 'secondary'"
:style="{ color: !stageId ? 'inherit' : undefined, marginInlineStart: '6px' }"
>
{{ formatCount(totalLeadsCount) }}
</a-typography-text>
</a-button>
<a-button
v-for="card in stageCards"
:key="card.key"
:type="isStatActive(card) ? 'primary' : 'default'"
:disabled="!card.stageId"
@click="selectStatCard(card)"
>
{{ card.label }}
<a-typography-text
:type="isStatActive(card) ? undefined : 'secondary'"
:style="{ color: isStatActive(card) ? 'inherit' : undefined, marginInlineStart: '6px' }"
>
{{ formatCount(card.count) }}
</a-typography-text>
</a-button>
</a-flex>
<a-space :size="8"> <a-space :size="8">
<FilterOutlined /> <TableColumnsMenu table-key="leads" align="end" />
<a-typography-text strong>فیلترها</a-typography-text> <a-radio-group
value="list"
button-style="solid"
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
>
<a-radio-button value="list">
<UnorderedListOutlined />
</a-radio-button>
<a-radio-button value="board">
<AppstoreOutlined />
</a-radio-button>
</a-radio-group>
</a-space> </a-space>
<a-select </a-flex>
:value="sort"
style="width: 11rem" <a-flex v-if="isMobile" vertical :gap="8">
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
<a-input-search <a-input-search
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو..." placeholder="جستجو نام، موبایل، شهر…"
enter-button="جستجو" style="width: 100%"
style="width: 16rem; max-width: 100%"
@search="onSearch" @search="onSearch"
> >
<template #prefix><SearchOutlined /></template> <template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</a-button>
</template>
</a-input-search> </a-input-search>
<a-select <a-badge
:value="cityFilter || undefined" :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 allow-clear
placeholder="همه مناطق" placeholder="جستجو نام، موبایل، شهر…"
style="width: 9.5rem" style="width: 15rem; max-width: 100%"
:options="cityOptions.map((c) => ({ value: c, label: c }))" @search="onSearch"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))" >
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</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>
<a-select
:value="sort"
style="width: 10rem"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/> />
<a-select <a-select
:value="sourceFilter || undefined" :value="assigneeSelectValue"
allow-clear allow-clear
placeholder="همه منابع" show-search
style="width: 9.5rem" option-filter-prop="label"
:options="sourceOptions.map((s) => ({ value: s, label: s }))" placeholder="مسئول"
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))" 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 <a-select
:value="stageId || undefined" :value="followUpFilter || undefined"
allow-clear allow-clear
placeholder="همه وضعیت" placeholder="پیگیری"
style="width: 9.5rem" style="width: 13rem"
:options="stages.map((s) => ({ value: s.id, label: s.name }))" :options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
@update:value="(v) => setStageFilter(v == null ? '' : String(v))" @update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
/> />
</a-flex> </a-flex>
<a-space :size="8">
<TableColumnsMenu table-key="leads" align="end" />
<a-radio-group
value="list"
button-style="solid"
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
>
<a-radio-button value="list">
<UnorderedListOutlined />
</a-radio-button>
<a-radio-button value="board">
<AppstoreOutlined />
</a-radio-button>
</a-radio-group>
</a-space>
</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>