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