feat: multi-select contacts filter panel for groups sources and mood
This commit is contained in:
+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