feat: multi-select contacts filter panel for groups sources and mood

This commit is contained in:
2026-10-21 18:07:21 +03:30
parent 29d99cbded
commit 50d7cad66e
6 changed files with 573 additions and 144 deletions
+13
View File
@@ -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 };
+3 -3
View File
@@ -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,
+8 -6
View File
@@ -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;
}
+13 -12
View File
@@ -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
View File
@@ -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;