fix(web): search and multi-select data scope filters
Long group and source lists overflowed the team settings panel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
import type {
|
||||
DataAccessOwnerKind,
|
||||
DataAccessRule,
|
||||
@@ -15,8 +16,6 @@ import {
|
||||
PERSON_MOODS,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { UiButton } from '@/components/ui';
|
||||
|
||||
export type IdOption = { id: string; name: string };
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -212,11 +211,37 @@ type ListKey =
|
||||
| 'boardIds'
|
||||
| 'typeIds';
|
||||
|
||||
function toggleInList(key: ListKey, value: string) {
|
||||
const current = new Set((filter.value[key] as string[] | undefined) ?? []);
|
||||
if (current.has(value)) current.delete(value);
|
||||
else current.add(value);
|
||||
const list = [...current];
|
||||
type Choice = { value: string; label: string };
|
||||
type PickerMode = 'multiple' | 'tags';
|
||||
type Picker = {
|
||||
key: ListKey;
|
||||
legend: string;
|
||||
mode: PickerMode;
|
||||
placeholder: string;
|
||||
notFound: string;
|
||||
hint?: string;
|
||||
options: Choice[];
|
||||
};
|
||||
|
||||
function namedOptions(items: IdOption[]): Choice[] {
|
||||
return items.map((item) => ({ value: item.id, label: item.name }));
|
||||
}
|
||||
|
||||
function withSelected(options: Choice[], selected: readonly string[] | undefined): Choice[] {
|
||||
const known = new Set(options.map((option) => option.value));
|
||||
const extra = (selected ?? [])
|
||||
.filter((id) => id && !known.has(id))
|
||||
.map((id) => ({ value: id, label: id }));
|
||||
return extra.length ? [...options, ...extra] : options;
|
||||
}
|
||||
|
||||
function listValue(key: ListKey): string[] {
|
||||
return ((filter.value[key] as string[] | undefined) ?? []).map(String);
|
||||
}
|
||||
|
||||
function setList(key: ListKey, value: unknown) {
|
||||
const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
|
||||
const list = [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
|
||||
patchFilter({ [key]: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
@@ -227,23 +252,121 @@ const sourceOptions = computed(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
|
||||
value: mood,
|
||||
label: PERSON_MOOD_LABELS[mood],
|
||||
}));
|
||||
|
||||
const pickers = computed((): Picker[] => {
|
||||
const rule = filter.value;
|
||||
const items: Picker[] = [];
|
||||
if (showGroups.value) {
|
||||
items.push({
|
||||
key: 'groupIds',
|
||||
legend: 'گروهها',
|
||||
mode: 'multiple',
|
||||
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
|
||||
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
|
||||
options: withSelected(namedOptions(props.groups), rule.groupIds),
|
||||
});
|
||||
}
|
||||
if (showImportSources.value) {
|
||||
items.push({
|
||||
key: 'sources',
|
||||
legend: 'منبع',
|
||||
mode: 'tags',
|
||||
placeholder: 'جستجو، انتخاب یا نوشتن منبع جدید…',
|
||||
notFound: 'برای منبع جدید Enter بزنید',
|
||||
hint: 'چند منبع را با هم انتخاب کنید. نام تازه را بنویسید و Enter بزنید.',
|
||||
options: withSelected(sourceOptions.value, rule.sources),
|
||||
});
|
||||
}
|
||||
if (showLeadSources.value) {
|
||||
items.push({
|
||||
key: 'sources',
|
||||
legend: 'منبع لید',
|
||||
mode: 'tags',
|
||||
placeholder: 'مثلاً اینستاگرام، سایت…',
|
||||
notFound: 'نام را بنویسید و Enter بزنید',
|
||||
hint: 'هر منبع را بنویسید و با Enter اضافه کنید.',
|
||||
options: withSelected([], rule.sources),
|
||||
});
|
||||
}
|
||||
if (showNoteTypes.value) {
|
||||
items.push({
|
||||
key: 'sources',
|
||||
legend: 'نوع یادداشت',
|
||||
mode: 'multiple',
|
||||
placeholder: 'جستجو و انتخاب نوع…',
|
||||
notFound: 'موردی پیدا نشد',
|
||||
options: withSelected(noteTypeOptions, rule.sources),
|
||||
});
|
||||
}
|
||||
if (showTicketStatuses.value) {
|
||||
items.push({
|
||||
key: 'sources',
|
||||
legend: 'وضعیت تیکت',
|
||||
mode: 'multiple',
|
||||
placeholder: 'جستجو و انتخاب وضعیت…',
|
||||
notFound: 'موردی پیدا نشد',
|
||||
options: withSelected(ticketStatusOptions, rule.sources),
|
||||
});
|
||||
}
|
||||
if (showMoods.value) {
|
||||
items.push({
|
||||
key: 'moods',
|
||||
legend: 'حال مشتری',
|
||||
mode: 'multiple',
|
||||
placeholder: 'جستجو و انتخاب حال مشتری…',
|
||||
notFound: 'موردی پیدا نشد',
|
||||
options: withSelected(moodOptions, rule.moods),
|
||||
});
|
||||
}
|
||||
if (showPipelines.value) {
|
||||
items.push({
|
||||
key: 'pipelineIds',
|
||||
legend: 'پایپلاین',
|
||||
mode: 'multiple',
|
||||
placeholder: props.pipelines.length ? 'جستجو و انتخاب پایپلاین…' : 'پایپلاینی نیست',
|
||||
notFound: props.pipelines.length ? 'موردی پیدا نشد' : 'پایپلاینی نیست',
|
||||
options: withSelected(namedOptions(props.pipelines), rule.pipelineIds),
|
||||
});
|
||||
}
|
||||
if (showStages.value) {
|
||||
items.push({
|
||||
key: 'stageIds',
|
||||
legend: 'مرحله',
|
||||
mode: 'multiple',
|
||||
placeholder: props.stages.length ? 'جستجو و انتخاب مرحله…' : 'مرحلهای نیست',
|
||||
notFound: props.stages.length ? 'موردی پیدا نشد' : 'مرحلهای نیست',
|
||||
options: withSelected(namedOptions(props.stages), rule.stageIds),
|
||||
});
|
||||
}
|
||||
if (showBoards.value) {
|
||||
items.push({
|
||||
key: 'boardIds',
|
||||
legend: boardsLegend.value,
|
||||
mode: 'multiple',
|
||||
placeholder: props.boards.length ? 'جستجو و انتخاب…' : 'بوردی نیست',
|
||||
notFound: props.boards.length ? 'موردی پیدا نشد' : 'بوردی نیست',
|
||||
options: withSelected(namedOptions(props.boards), rule.boardIds),
|
||||
});
|
||||
}
|
||||
if (showTypes.value) {
|
||||
items.push({
|
||||
key: 'typeIds',
|
||||
legend: typesLegend.value,
|
||||
mode: 'multiple',
|
||||
placeholder: props.activityTypes.length ? 'جستجو و انتخاب…' : 'موردی تعریف نشده',
|
||||
notFound: props.activityTypes.length ? 'موردی پیدا نشد' : 'موردی تعریف نشده',
|
||||
options: withSelected(namedOptions(props.activityTypes), rule.typeIds),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const hasLimits = computed(() => sectionHasDataAccessLimits(props.modelValue));
|
||||
|
||||
const customSource = ref('');
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
customSource.value = '';
|
||||
},
|
||||
);
|
||||
|
||||
function addCustomSource() {
|
||||
const v = customSource.value.trim();
|
||||
if (!v) return;
|
||||
toggleInList('sources', v);
|
||||
customSource.value = '';
|
||||
}
|
||||
|
||||
const filterSummary = computed(() => {
|
||||
const rule = filter.value;
|
||||
const parts: string[] = [];
|
||||
@@ -277,7 +400,7 @@ const filterSummary = computed(() => {
|
||||
<p class="hint">
|
||||
فقط رکوردهایی دیده میشوند که با
|
||||
<strong>همهٔ فیلترهای پرشده</strong>
|
||||
جور باشند. در هر ردیف، چند تیک یعنی هر کدام کافی است.
|
||||
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
|
||||
</p>
|
||||
<p v-if="hadMultipleRules" class="hint warn">
|
||||
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
||||
@@ -305,160 +428,23 @@ const filterSummary = computed(() => {
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showGroups" :disabled="disabled">
|
||||
<legend>گروهها</legend>
|
||||
<p v-if="!groups.length" class="muted">گروهی تعریف نشده.</p>
|
||||
<label v-for="g in groups" :key="g.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.groupIds?.includes(g.id)"
|
||||
@change="toggleInList('groupIds', g.id)"
|
||||
/>
|
||||
{{ g.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showImportSources" :disabled="disabled">
|
||||
<legend>منبع</legend>
|
||||
<label v-for="s in sourceOptions" :key="s.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.sources?.includes(s.value)"
|
||||
@change="toggleInList('sources', s.value)"
|
||||
/>
|
||||
{{ s.label }}
|
||||
</label>
|
||||
<div class="custom-source">
|
||||
<input
|
||||
v-model="customSource"
|
||||
type="text"
|
||||
placeholder="منبع سفارشی…"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="addCustomSource"
|
||||
/>
|
||||
<UiButton
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click.prevent="addCustomSource"
|
||||
>
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showLeadSources" :disabled="disabled">
|
||||
<legend>منبع لید</legend>
|
||||
<div class="custom-source">
|
||||
<input
|
||||
v-model="customSource"
|
||||
type="text"
|
||||
placeholder="مثلاً اینستاگرام، سایت…"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="addCustomSource"
|
||||
/>
|
||||
<UiButton
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click.prevent="addCustomSource"
|
||||
>
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
<label v-for="s in filter.sources ?? []" :key="s" class="chip">
|
||||
<input type="checkbox" checked @change="toggleInList('sources', s)" />
|
||||
{{ s }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showNoteTypes" :disabled="disabled">
|
||||
<legend>نوع یادداشت</legend>
|
||||
<label v-for="t in noteTypeOptions" :key="t.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.sources?.includes(t.value)"
|
||||
@change="toggleInList('sources', t.value)"
|
||||
/>
|
||||
{{ t.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showTicketStatuses" :disabled="disabled">
|
||||
<legend>وضعیت تیکت</legend>
|
||||
<label v-for="t in ticketStatusOptions" :key="t.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.sources?.includes(t.value)"
|
||||
@change="toggleInList('sources', t.value)"
|
||||
/>
|
||||
{{ t.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showMoods" :disabled="disabled">
|
||||
<legend>حال مشتری</legend>
|
||||
<label v-for="m in PERSON_MOODS" :key="m" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.moods?.includes(m)"
|
||||
@change="toggleInList('moods', m)"
|
||||
/>
|
||||
{{ PERSON_MOOD_LABELS[m] }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showPipelines" :disabled="disabled">
|
||||
<legend>پایپلاین</legend>
|
||||
<p v-if="!pipelines.length" class="muted">پایپلاینی نیست.</p>
|
||||
<label v-for="p in pipelines" :key="p.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.pipelineIds?.includes(p.id)"
|
||||
@change="toggleInList('pipelineIds', p.id)"
|
||||
/>
|
||||
{{ p.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showStages" :disabled="disabled">
|
||||
<legend>مرحله</legend>
|
||||
<p v-if="!stages.length" class="muted">مرحلهای نیست.</p>
|
||||
<label v-for="s in stages" :key="s.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.stageIds?.includes(s.id)"
|
||||
@change="toggleInList('stageIds', s.id)"
|
||||
/>
|
||||
{{ s.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showBoards" :disabled="disabled">
|
||||
<legend>{{ boardsLegend }}</legend>
|
||||
<p v-if="!boards.length" class="muted">بوردی نیست.</p>
|
||||
<label v-for="b in boards" :key="b.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.boardIds?.includes(b.id)"
|
||||
@change="toggleInList('boardIds', b.id)"
|
||||
/>
|
||||
{{ b.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showTypes" :disabled="disabled">
|
||||
<legend>{{ typesLegend }}</legend>
|
||||
<p v-if="!activityTypes.length" class="muted">موردی تعریف نشده.</p>
|
||||
<label v-for="t in activityTypes" :key="t.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.typeIds?.includes(t.id)"
|
||||
@change="toggleInList('typeIds', t.id)"
|
||||
/>
|
||||
{{ t.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<label v-for="picker in pickers" :key="picker.legend" class="pick">
|
||||
<span>{{ picker.legend }}</span>
|
||||
<a-select
|
||||
:value="listValue(picker.key)"
|
||||
:mode="picker.mode"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="picker.options"
|
||||
:disabled="disabled"
|
||||
:placeholder="picker.placeholder"
|
||||
:not-found-content="picker.notFound"
|
||||
style="width: 100%"
|
||||
@update:value="setList(picker.key, $event)"
|
||||
/>
|
||||
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!hasLimits" class="hint">
|
||||
@@ -511,17 +497,14 @@ legend {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.custom-source {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.custom-source input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
.pick > span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.compact fieldset {
|
||||
padding: 0.45rem 0.55rem;
|
||||
|
||||
Reference in New Issue
Block a user