diff --git a/apps/api/src/common/search.ts b/apps/api/src/common/search.ts index 918d58e..39404e3 100644 --- a/apps/api/src/common/search.ts +++ b/apps/api/src/common/search.ts @@ -79,6 +79,19 @@ export function buildSearchWhere( 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 }; diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index 70d6b3a..dbbabbe 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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, diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index d03b8fd..fd133ff 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -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; } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 0e768e8..151192c 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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(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; diff --git a/apps/web/src/components/ContactsFilterPanel.vue b/apps/web/src/components/ContactsFilterPanel.vue new file mode 100644 index 0000000..8ec2e77 --- /dev/null +++ b/apps/web/src/components/ContactsFilterPanel.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 903091f..6d8068f 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -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(() => (route.query.sort === 'newest' ? 'newest' : 'name')); -const moodFilter = computed(() => { - 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(() => { - 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(() => ({ + 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(() => { + 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) { 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('/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);
  • -
    - - +
    + @@ -568,60 +603,20 @@ onMounted(load); @apply="applyPeopleSavedFilters" /> -
    - - -
    - -
    - - - -
    - -
    - - + {{ tag.label }} + + +

    {{ importMsg }}

    @@ -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;