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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user