feat: multi-select contacts filter panel for groups sources and mood
This commit is contained in:
@@ -79,6 +79,19 @@ export function buildSearchWhere<W extends object>(
|
||||
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
|
||||
}
|
||||
|
||||
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
|
||||
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
|
||||
if (!raw) return [];
|
||||
return [
|
||||
...new Set(
|
||||
String(raw)
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, max);
|
||||
}
|
||||
|
||||
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
|
||||
export function ci(term: string) {
|
||||
return { contains: term, mode: 'insensitive' as const };
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
CustomerGroupsService,
|
||||
@@ -105,10 +105,10 @@ export class CompaniesService {
|
||||
{ city: ci(t) },
|
||||
{ province: ci(t) },
|
||||
]);
|
||||
const groupId = opts?.groupId?.trim();
|
||||
const groupIds = splitCsv(opts?.groupId);
|
||||
const and: Prisma.CompanyWhereInput[] = [
|
||||
...(search ? [search] : []),
|
||||
...(groupId ? [{ groupLinks: { some: { groupId } } }] : []),
|
||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||
];
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
|
||||
@@ -50,18 +50,20 @@ export class ListQueryDto {
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
/** وضعیت مشتری؛ چندتایی با کاما */
|
||||
@IsOptional()
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
mood?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['name', 'newest'])
|
||||
sort?: 'name' | 'newest';
|
||||
|
||||
/** فیلتر منبع ورود شخص */
|
||||
/** فیلتر منبع ورود شخص؛ چندتایی با کاما */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
@MaxLength(400)
|
||||
importSource?: string;
|
||||
|
||||
/** استان شرکت مرتبط */
|
||||
@@ -87,10 +89,10 @@ export class ListQueryDto {
|
||||
@MaxLength(12)
|
||||
disc?: string;
|
||||
|
||||
/** فیلتر گروه مشتری */
|
||||
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروهها) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
@MaxLength(2000)
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
PersonMood,
|
||||
UpdatePersonRequest,
|
||||
} from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||
import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
@@ -45,7 +45,7 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import { selectMonthBirthdays } from './birthday-month';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||
import {
|
||||
normalizePersonImportSource,
|
||||
personImportSourceLabel,
|
||||
@@ -150,7 +150,7 @@ export class PeopleService implements OnModuleInit {
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
@@ -159,9 +159,11 @@ export class PeopleService implements OnModuleInit {
|
||||
groupId?: string;
|
||||
},
|
||||
): Prisma.PersonWhereInput {
|
||||
const sourceFilter = opts?.importSource?.trim()
|
||||
? normalizePersonImportSource(opts.importSource)
|
||||
: undefined;
|
||||
const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
|
||||
const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
|
||||
(PERSON_MOODS as readonly string[]).includes(m),
|
||||
);
|
||||
const groupIds = splitCsv(opts?.groupId);
|
||||
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
|
||||
{ firstName: ci(t) },
|
||||
{ lastName: ci(t) },
|
||||
@@ -201,13 +203,12 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
const disc = discFilter(opts?.disc);
|
||||
if (disc) and.push(disc);
|
||||
const groupId = opts?.groupId?.trim();
|
||||
if (groupId) and.push({ groupLinks: { some: { groupId } } });
|
||||
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||
...(moods.length ? { mood: { in: moods } } : {}),
|
||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
};
|
||||
}
|
||||
@@ -218,7 +219,7 @@ export class PeopleService implements OnModuleInit {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
sort?: 'name' | 'newest';
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
@@ -257,7 +258,7 @@ export class PeopleService implements OnModuleInit {
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
opts?: {
|
||||
mood?: PersonMood;
|
||||
mood?: string;
|
||||
importSource?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import type { CustomerGroupDto, PersonImportSourceStatDto, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
import { groupSwatch } from '@/lib/customer-groups';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
export type ContactsFilterValue = {
|
||||
groups: string[];
|
||||
sources: string[];
|
||||
moods: PersonMood[];
|
||||
disc: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
tab: 'people' | 'companies';
|
||||
value: ContactsFilterValue;
|
||||
groups: CustomerGroupDto[];
|
||||
sourceStats: PersonImportSourceStatDto[];
|
||||
canBehavior: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ apply: [value: ContactsFilterValue]; close: [] }>();
|
||||
|
||||
const draft = reactive<ContactsFilterValue>({ groups: [], sources: [], moods: [], disc: '' });
|
||||
const groupQuery = ref('');
|
||||
const GROUP_SEARCH_MIN = 8;
|
||||
const DISC_TYPES = ['D', 'I', 'S', 'C'];
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
draft.groups = [...props.value.groups];
|
||||
draft.sources = [...props.value.sources];
|
||||
draft.moods = [...props.value.moods];
|
||||
draft.disc = props.value.disc;
|
||||
groupQuery.value = '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const isPeople = computed(() => props.tab === 'people');
|
||||
|
||||
function groupCount(g: CustomerGroupDto) {
|
||||
return isPeople.value ? g.personCount : g.companyCount;
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const q = groupQuery.value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک');
|
||||
const list = props.groups.filter((g) => groupCount(g) > 0 || draft.groups.includes(g.id));
|
||||
return q ? list.filter((g) => g.name.includes(q)) : list;
|
||||
});
|
||||
|
||||
const draftCount = computed(
|
||||
() =>
|
||||
draft.groups.length +
|
||||
(isPeople.value ? draft.sources.length + draft.moods.length + (draft.disc ? 1 : 0) : 0),
|
||||
);
|
||||
|
||||
function toggle<T extends string>(list: T[], value: T) {
|
||||
const idx = list.indexOf(value);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
else list.push(value);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
draft.groups = [];
|
||||
draft.sources = [];
|
||||
draft.moods = [];
|
||||
draft.disc = '';
|
||||
}
|
||||
|
||||
function apply() {
|
||||
emit('apply', {
|
||||
groups: [...draft.groups],
|
||||
sources: [...draft.sources],
|
||||
moods: [...draft.moods],
|
||||
disc: draft.disc,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="panel" role="dialog" aria-label="فیلترها" @click.stop>
|
||||
<header class="panel-head">
|
||||
<strong>فیلترها</strong>
|
||||
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<section class="sec">
|
||||
<h4>
|
||||
گروه
|
||||
<span v-if="draft.groups.length" class="sel">{{ toFaDigits(draft.groups.length) }} انتخاب</span>
|
||||
</h4>
|
||||
<label v-if="groups.length > GROUP_SEARCH_MIN" class="find">
|
||||
<UiIcon name="search" :size="14" />
|
||||
<input v-model="groupQuery" type="search" placeholder="جستجوی گروه…" />
|
||||
</label>
|
||||
<ul v-if="filteredGroups.length" class="checks">
|
||||
<li v-for="g in filteredGroups" :key="g.id">
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="draft.groups.includes(g.id)"
|
||||
@change="toggle(draft.groups, g.id)"
|
||||
/>
|
||||
<i class="dot" :style="{ background: groupSwatch(g.color).fg }" />
|
||||
<span class="lbl">{{ g.name }}</span>
|
||||
<em>{{ toFaDigits(groupCount(g)) }}</em>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">{{ groupQuery ? 'گروهی با این نام نیست.' : 'گروهی ثبت نشده است.' }}</p>
|
||||
</section>
|
||||
|
||||
<template v-if="isPeople">
|
||||
<section v-if="sourceStats.length" class="sec">
|
||||
<h4>
|
||||
منبع
|
||||
<span v-if="draft.sources.length" class="sel">{{ toFaDigits(draft.sources.length) }} انتخاب</span>
|
||||
</h4>
|
||||
<ul class="checks">
|
||||
<li v-for="row in sourceStats" :key="row.source">
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="draft.sources.includes(row.source)"
|
||||
@change="toggle(draft.sources, row.source)"
|
||||
/>
|
||||
<span class="lbl">{{ row.label }}</span>
|
||||
<em>{{ toFaDigits(row.count) }}</em>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<h4>وضعیت مشتری</h4>
|
||||
<div class="pills">
|
||||
<button
|
||||
v-for="m in PERSON_MOODS"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ on: draft.moods.includes(m) }"
|
||||
@click="toggle(draft.moods, m)"
|
||||
>
|
||||
{{ PERSON_MOOD_LABELS[m] }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canBehavior" class="sec">
|
||||
<h4>تیپ DISC</h4>
|
||||
<div class="pills">
|
||||
<button
|
||||
v-for="t in DISC_TYPES"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ on: draft.disc === t }"
|
||||
@click="draft.disc = draft.disc === t ? '' : t"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<footer class="panel-foot">
|
||||
<UiButton variant="ghost" size="sm" @click="emit('close')">انصراف</UiButton>
|
||||
<UiButton variant="primary" size="sm" @click="apply">
|
||||
اعمال فیلتر<template v-if="draftCount"> ({{ toFaDigits(draftCount) }})</template>
|
||||
</UiButton>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
width: min(22rem, calc(100vw - 1.5rem));
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(72vh, 36rem);
|
||||
}
|
||||
|
||||
.panel-head,
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent, #0f766e);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.sec {
|
||||
padding: 0.55rem 0;
|
||||
}
|
||||
|
||||
.sec + .sec {
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.sec h4 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sel {
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
|
||||
.find {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 0.55rem;
|
||||
height: 2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.find input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.checks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 13rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.32rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.check:hover {
|
||||
background: var(--surface-subtle, #f8fafc);
|
||||
}
|
||||
|
||||
.check input {
|
||||
accent-color: #14b8a6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check em {
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill.on {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
+172
-123
@@ -34,7 +34,6 @@ import type {
|
||||
import {
|
||||
brand,
|
||||
PERSON_IMPORT_SOURCE_LABELS,
|
||||
PERSON_IMPORT_SOURCES,
|
||||
} from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
import { canUseBehavior } from '@/lib/behavior-access';
|
||||
@@ -43,6 +42,7 @@ import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
|
||||
import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue';
|
||||
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
|
||||
import ContactsFilterPanel, { type ContactsFilterValue } from '@/components/ContactsFilterPanel.vue';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
|
||||
type Tab = 'people' | 'companies';
|
||||
@@ -61,35 +61,53 @@ const page = computed(() => {
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||||
});
|
||||
const sort = computed<SortKey>(() => (route.query.sort === 'newest' ? 'newest' : 'name'));
|
||||
const moodFilter = computed<PersonMood | 'all'>(() => {
|
||||
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
|
||||
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
|
||||
});
|
||||
function queryList(key: string): string[] {
|
||||
const raw = route.query[key];
|
||||
if (typeof raw !== 'string') return [];
|
||||
return [...new Set(raw.split(',').map((v) => v.trim()).filter(Boolean))];
|
||||
}
|
||||
function csv(list: string[]): string | undefined {
|
||||
return list.length ? list.join(',') : undefined;
|
||||
}
|
||||
|
||||
const moodFilters = computed(() =>
|
||||
queryList('mood').filter((m): m is PersonMood => PERSON_MOODS.includes(m as PersonMood)),
|
||||
);
|
||||
const auth = useAuthStore();
|
||||
const canBehavior = computed(() => canUseBehavior(auth.user));
|
||||
const discFilter = computed(() => {
|
||||
const value = typeof route.query.disc === 'string' ? route.query.disc.toUpperCase() : '';
|
||||
return /^[DISC](\/[DISC]){0,3}$/.test(value) ? value : '';
|
||||
});
|
||||
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
|
||||
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
|
||||
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
|
||||
? (value as PersonImportSource)
|
||||
: 'all';
|
||||
});
|
||||
const groupFilter = computed(() =>
|
||||
typeof route.query.group === 'string' ? route.query.group : '',
|
||||
);
|
||||
const sourceFilters = computed(() => queryList('source'));
|
||||
const groupFilters = computed(() => queryList('group'));
|
||||
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
|
||||
const canManageGroups = computed(() => canManageSettings(auth.user?.role));
|
||||
const groupsManagerOpen = ref(false);
|
||||
const visibleGroups = computed(() =>
|
||||
customerGroups.value.filter(
|
||||
(g) =>
|
||||
g.id === groupFilter.value ||
|
||||
(tab.value === 'people' ? g.personCount : g.companyCount) > 0,
|
||||
),
|
||||
);
|
||||
|
||||
const filterValue = computed<ContactsFilterValue>(() => ({
|
||||
groups: groupFilters.value,
|
||||
sources: sourceFilters.value,
|
||||
moods: moodFilters.value,
|
||||
disc: discFilter.value,
|
||||
}));
|
||||
|
||||
type FilterTag = { kind: 'group' | 'source' | 'mood' | 'disc'; value: string; label: string; color?: string };
|
||||
const activeTags = computed<FilterTag[]>(() => {
|
||||
const tags: FilterTag[] = groupFilters.value.map((id) => {
|
||||
const g = customerGroups.value.find((x) => x.id === id);
|
||||
return { kind: 'group', value: id, label: g?.name ?? 'گروه حذفشده', color: g?.color };
|
||||
});
|
||||
if (tab.value !== 'people') return tags;
|
||||
for (const s of sourceFilters.value) {
|
||||
tags.push({ kind: 'source', value: s, label: `منبع: ${sourceLabel(s)}` });
|
||||
}
|
||||
for (const m of moodFilters.value) {
|
||||
tags.push({ kind: 'mood', value: m, label: moodLabel(m) });
|
||||
}
|
||||
if (discFilter.value) tags.push({ kind: 'disc', value: discFilter.value, label: `DISC: ${discFilter.value}` });
|
||||
return tags;
|
||||
});
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
@@ -159,32 +177,27 @@ function onPageSize(size: number) {
|
||||
const peopleSavedFilters = computed(() => ({
|
||||
q: q.value.trim() || undefined,
|
||||
sort: sort.value === 'name' ? undefined : sort.value,
|
||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||
mood: csv(moodFilters.value),
|
||||
source: csv(sourceFilters.value),
|
||||
disc: discFilter.value || undefined,
|
||||
group: groupFilter.value || undefined,
|
||||
group: csv(groupFilters.value),
|
||||
}));
|
||||
|
||||
function savedText(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
}
|
||||
|
||||
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||||
const nextQ = typeof filters.q === 'string' ? filters.q : '';
|
||||
q.value = nextQ;
|
||||
const nextSort = filters.sort === 'newest' ? 'newest' : 'name';
|
||||
const nextMood =
|
||||
typeof filters.mood === 'string' &&
|
||||
PERSON_MOODS.includes(filters.mood as PersonMood)
|
||||
? (filters.mood as PersonMood)
|
||||
: 'all';
|
||||
const nextSource =
|
||||
typeof filters.source === 'string' &&
|
||||
PERSON_IMPORT_SOURCES.includes(filters.source as PersonImportSource)
|
||||
? (filters.source as PersonImportSource)
|
||||
: 'all';
|
||||
patchQuery({
|
||||
q: nextQ.trim() || undefined,
|
||||
sort: nextSort === 'name' ? undefined : nextSort,
|
||||
mood: nextMood === 'all' ? undefined : nextMood,
|
||||
source: nextSource === 'all' ? undefined : nextSource,
|
||||
group: typeof filters.group === 'string' && filters.group ? filters.group : undefined,
|
||||
mood: savedText(filters.mood),
|
||||
source: savedText(filters.source),
|
||||
disc: savedText(filters.disc),
|
||||
group: savedText(filters.group),
|
||||
page: undefined,
|
||||
tab: undefined,
|
||||
});
|
||||
@@ -250,11 +263,10 @@ async function load() {
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
sort: sort.value,
|
||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||
importSource:
|
||||
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||
mood: csv(moodFilters.value),
|
||||
importSource: csv(sourceFilters.value),
|
||||
disc: discFilter.value || undefined,
|
||||
groupId: groupFilter.value || undefined,
|
||||
groupId: csv(groupFilters.value),
|
||||
},
|
||||
}),
|
||||
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
|
||||
@@ -271,7 +283,7 @@ async function load() {
|
||||
q: q.value || undefined,
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
groupId: groupFilter.value || undefined,
|
||||
groupId: csv(groupFilters.value),
|
||||
},
|
||||
});
|
||||
companies.value = data;
|
||||
@@ -299,7 +311,6 @@ function setTab(next: Tab) {
|
||||
patchQuery({
|
||||
tab: next === 'people' ? undefined : next,
|
||||
page: undefined,
|
||||
mood: next === 'people' ? (moodFilter.value === 'all' ? undefined : moodFilter.value) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,30 +323,38 @@ function setSort(next: SortKey) {
|
||||
patchQuery({ sort: next === 'name' ? undefined : next, page: undefined });
|
||||
}
|
||||
|
||||
function setMood(next: PersonMood | 'all') {
|
||||
function applyFilters(next: ContactsFilterValue) {
|
||||
filterOpen.value = false;
|
||||
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
|
||||
const people = tab.value === 'people';
|
||||
patchQuery({
|
||||
group: csv(next.groups),
|
||||
...(people
|
||||
? { source: csv(next.sources), mood: csv(next.moods), disc: next.disc || undefined }
|
||||
: {}),
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setDisc(next: string) {
|
||||
patchQuery({ disc: next || undefined, page: undefined });
|
||||
function removeTag(tag: FilterTag) {
|
||||
const without = (list: string[]) => csv(list.filter((v) => v !== tag.value));
|
||||
if (tag.kind === 'group') patchQuery({ group: without(groupFilters.value), page: undefined });
|
||||
else if (tag.kind === 'source') patchQuery({ source: without(sourceFilters.value), page: undefined });
|
||||
else if (tag.kind === 'mood') patchQuery({ mood: without(moodFilters.value), page: undefined });
|
||||
else patchQuery({ disc: undefined, page: undefined });
|
||||
}
|
||||
|
||||
function setGroup(next: string) {
|
||||
patchQuery({ group: next || undefined, page: undefined });
|
||||
function clearFilters() {
|
||||
patchQuery({
|
||||
group: undefined,
|
||||
...(tab.value === 'people' ? { source: undefined, mood: undefined, disc: undefined } : {}),
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function onGroupsChanged() {
|
||||
await load();
|
||||
}
|
||||
|
||||
function setSource(next: PersonImportSource | 'all') {
|
||||
patchQuery({
|
||||
source: next === 'all' ? undefined : next,
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setPage(next: number) {
|
||||
if (next < 1 || next > pageCount.value) return;
|
||||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||||
@@ -541,22 +560,38 @@ onMounted(load);
|
||||
<li><button type="button" :class="{ on: sort === 'newest' }" @click.stop="setSort('newest')">جدیدترین</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="tab === 'people'" class="pop">
|
||||
<button class="tool" type="button" @click.stop="filterOpen = !filterOpen; sortOpen = false">
|
||||
<div class="pop">
|
||||
<button
|
||||
class="tool"
|
||||
:class="{ active: activeTags.length }"
|
||||
type="button"
|
||||
@click.stop="filterOpen = !filterOpen; sortOpen = false"
|
||||
>
|
||||
<UiIcon name="filter" :size="16" />
|
||||
<span class="tool-label">فیلتر</span>
|
||||
<b v-if="activeTags.length" class="tool-count">{{ toFaDigits(activeTags.length) }}</b>
|
||||
</button>
|
||||
<ul v-if="filterOpen" class="menu">
|
||||
<li>
|
||||
<button type="button" :class="{ on: moodFilter === 'all' }" @click.stop="setMood('all')">همه</button>
|
||||
</li>
|
||||
<li v-for="mood in PERSON_MOODS" :key="mood">
|
||||
<button type="button" :class="{ on: moodFilter === mood }" @click.stop="setMood(mood)">
|
||||
{{ moodLabel(mood) }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<ContactsFilterPanel
|
||||
:open="filterOpen"
|
||||
:tab="tab"
|
||||
:value="filterValue"
|
||||
:groups="customerGroups"
|
||||
:source-stats="sourceStats"
|
||||
:can-behavior="canBehavior"
|
||||
@apply="applyFilters"
|
||||
@close="filterOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="canManageGroups"
|
||||
class="tool"
|
||||
type="button"
|
||||
title="مدیریت گروههای مشتری"
|
||||
@click.stop="groupsManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="settings" :size="16" />
|
||||
<span class="tool-label">گروهها</span>
|
||||
</button>
|
||||
<TableColumnsMenu :table-key="tab === 'people' ? 'contacts.people' : 'contacts.companies'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,60 +603,20 @@ onMounted(load);
|
||||
@apply="applyPeopleSavedFilters"
|
||||
/>
|
||||
|
||||
<div v-if="tab === 'people' && sourceStats.length" class="source-stats">
|
||||
<button
|
||||
type="button"
|
||||
class="source-chip"
|
||||
:class="{ on: sourceFilter === 'all' }"
|
||||
@click="setSource('all')"
|
||||
<div v-if="activeTags.length" class="filter-tags">
|
||||
<span class="filter-tags-label">فیلترها:</span>
|
||||
<span
|
||||
v-for="tag in activeTags"
|
||||
:key="`${tag.kind}:${tag.value}`"
|
||||
class="filter-tag"
|
||||
:style="tag.color ? { color: groupSwatch(tag.color).fg, background: groupSwatch(tag.color).bg } : undefined"
|
||||
>
|
||||
همه منابع
|
||||
</button>
|
||||
<button
|
||||
v-for="row in sourceStats"
|
||||
:key="row.source"
|
||||
type="button"
|
||||
class="source-chip"
|
||||
:class="{ on: sourceFilter === row.source }"
|
||||
@click="setSource(row.source as PersonImportSource)"
|
||||
>
|
||||
{{ row.label }}
|
||||
<em>{{ toFaDigits(row.count) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleGroups.length || canManageGroups" class="source-stats">
|
||||
<button type="button" class="source-chip" :class="{ on: !groupFilter }" @click="setGroup('')">
|
||||
همه گروهها
|
||||
</button>
|
||||
<button
|
||||
v-for="g in visibleGroups"
|
||||
:key="g.id"
|
||||
type="button"
|
||||
class="source-chip"
|
||||
:class="{ on: groupFilter === g.id }"
|
||||
:style="groupFilter === g.id ? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg } : undefined"
|
||||
@click="setGroup(g.id)"
|
||||
>
|
||||
{{ g.name }}
|
||||
<em>{{ toFaDigits(tab === 'people' ? g.personCount : g.companyCount) }}</em>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageGroups"
|
||||
type="button"
|
||||
class="source-chip manage-chip"
|
||||
@click="groupsManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="settings" :size="13" />
|
||||
مدیریت گروهها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'people' && canBehavior" class="source-stats">
|
||||
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپها</button>
|
||||
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
|
||||
{{ type }}
|
||||
</button>
|
||||
{{ tag.label }}
|
||||
<button type="button" :aria-label="`حذف فیلتر ${tag.label}`" @click="removeTag(tag)">
|
||||
<UiIcon name="x" :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="filter-clear" @click="clearFilters">پاک کردن همه</button>
|
||||
</div>
|
||||
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
@@ -1076,10 +1071,64 @@ h1 {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.manage-chip {
|
||||
border-style: dashed;
|
||||
.filter-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.filter-tags-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.filter-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.35rem 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-tag button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.filter-clear {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool.active {
|
||||
border-color: #14b8a6;
|
||||
color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
.tool-count {
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 999px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.tiny {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
|
||||
Reference in New Issue
Block a user