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('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,
});
}
+69
View File
@@ -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<string, string> = {
@@ -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<Prisma.LeadWhereInput>(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<PaginatedResponse<LeadDto>> {
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<FilteredIdsResponse> {
+495 -136
View File
@@ -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<LeadStageDto[]>([]);
const stats = ref<LeadStatsDto | null>(null);
const users = ref<OrgUserDto[]>([]);
const cityOptions = ref<string[]>([]);
const regionOptions = ref<string[]>([]);
const tradeOptions = ref<string[]>([]);
const sourceOptions = ref<string[]>([]);
const selected = ref<Set<string>>(new Set());
const moreFiltersOpen = ref(false);
const sortOpen = ref(false);
const rowMenuId = ref<string | null>(null);
const logoMenuId = ref<string | null>(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<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(
() => 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(() => {
</script>
<template>
<a-row :gutter="[24, 24]" @click="closePopups">
<a-row :gutter="[16, 16]" @click="closePopups">
<a-col :span="24">
<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-text type="secondary">
مدیریت و پیگیری تمام لیدهای ورودی
</a-typography-text>
</div>
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
<a-space :size="8" wrap>
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<template #icon><ExportOutlined /></template>
خروجی اکسل
</a-button>
<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>
ورود از اکسل
<template v-if="isMobile">
<a-dropdown :trigger="['click']" placement="bottomLeft">
<a-button :disabled="busy">
<template #icon><MoreOutlined /></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>
از مخاطبین گوشی
<template #overlay>
<a-menu>
<a-menu-item
key="export"
:disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
@click="exportExcel"
>
<ExportOutlined />
خروجی اکسل
</a-menu-item>
<a-menu-item key="excel" :disabled="busy">
<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>
</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">
<template #icon><PlusOutlined /></template>
لید جدید
{{ isMobile ? 'جدید' : 'لید جدید' }}
</a-button>
</a-space>
</a-flex>
</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-card :bordered="true">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-flex align="center" wrap="wrap" :gap="8" flex="1 1 auto">
<a-flex vertical :gap="12">
<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">
<FilterOutlined />
<a-typography-text strong>فیلترها</a-typography-text>
<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-select
:value="sort"
style="width: 11rem"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
</a-flex>
<a-flex v-if="isMobile" vertical :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو..."
enter-button="جستجو"
style="width: 16rem; max-width: 100%"
placeholder="جستجو نام، موبایل، شهر…"
style="width: 100%"
@search="onSearch"
>
<template #prefix><SearchOutlined /></template>
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</a-button>
</template>
</a-input-search>
<a-select
:value="cityFilter || undefined"
<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: 9.5rem"
:options="cityOptions.map((c) => ({ value: c, label: c }))"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
placeholder="جستجو نام، موبایل، شهر…"
style="width: 15rem; max-width: 100%"
@search="onSearch"
>
<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
:value="sourceFilter || undefined"
:value="assigneeSelectValue"
allow-clear
placeholder="همه منابع"
style="width: 9.5rem"
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
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="stageId || undefined"
:value="followUpFilter || undefined"
allow-clear
placeholder="همه وضعیت"
style="width: 9.5rem"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
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-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-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>