Files
CRM_Montzar/apps/web/src/components/DataAccessEditor.vue
T
MahdiandCursor 0daaef0b39 fix(web): calm hero flash, lead detail, and scope sources
Keep the dashboard backdrop out of flow on first paint, clamp long lead notes, scroll the quick-actions rail, return to the lead board, and only list import sources that exist in this environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 19:22:30 +03:30

560 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, watch } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
DataAccessSection,
PersonMood,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
PERSON_IMPORT_SOURCES,
PERSON_MOOD_LABELS,
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string };
const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[];
pipelines?: IdOption[];
boards?: IdOption[];
activityTypes?: IdOption[];
/** منبع‌هایی که در مشتریان همین محیط رکورد دارند */
importSources?: Choice[];
importSourcesReady?: boolean;
importSourcesFailed?: boolean;
disabled?: boolean;
compact?: boolean;
}>(),
{
section: 'customers',
groups: () => [],
stages: () => [],
pipelines: () => [],
boards: () => [],
activityTypes: () => [],
importSources: () => [],
importSourcesReady: false,
importSourcesFailed: false,
},
);
const emit = defineEmits<{
'update:modelValue': [SectionDataAccess];
}>();
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets');
const showStages = computed(
() =>
props.section === 'leads' ||
props.section === 'sales' ||
props.section === 'activities' ||
props.section === 'projects',
);
const showPipelines = computed(() => props.section === 'sales');
const showBoards = computed(
() => props.section === 'activities' || props.section === 'projects',
);
const showTypes = computed(
() => props.section === 'activities' || props.section === 'routines',
);
const ownerSelfLabel = computed(() => {
if (
props.section === 'leads' ||
props.section === 'activities' ||
props.section === 'routines' ||
props.section === 'tickets'
) {
return 'مسئول خودش';
}
if (props.section === 'notes') return 'نویسنده خودش';
return 'پرونده‌های خود';
});
const noteTypeOptions = [
{ value: 'general', label: 'عمومی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'important', label: 'مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'اقدام بعدی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'personal', label: 'شخصی' },
];
const ticketStatusOptions = [
{ value: 'open', label: 'باز' },
{ value: 'in_progress', label: 'در حال انجام' },
{ value: 'waiting', label: 'در انتظار' },
{ value: 'resolved', label: 'حل‌شده' },
{ value: 'closed', label: 'بسته' },
];
const typesLegend = computed(() =>
props.section === 'routines' ? 'دسته روتین' : 'نوع فعالیت',
);
const boardsLegend = computed(() =>
props.section === 'projects' ? 'بورد پروژه' : 'بورد فعالیت',
);
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! };
const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>();
const moods = new Set<PersonMood>();
const stageIds = new Set<string>();
const pipelineIds = new Set<string>();
const boardIds = new Set<string>();
const typeIds = new Set<string>();
for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id);
for (const id of r.pipelineIds ?? []) pipelineIds.add(id);
for (const id of r.boardIds ?? []) boardIds.add(id);
for (const id of r.typeIds ?? []) typeIds.add(id);
}
const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds];
if (pipelineIds.size) out.pipelineIds = [...pipelineIds];
if (boardIds.size) out.boardIds = [...boardIds];
if (typeIds.size) out.typeIds = [...typeIds];
return out;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean(
rule.owners?.length ||
rule.groupIds?.length ||
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length,
);
}
const hadMultipleRules = computed(
() => (props.modelValue?.rules?.length ?? 0) > 1,
);
const mode = computed({
get: () => (props.modelValue?.mode === 'rules' ? 'rules' : 'all'),
set: (next: 'all' | 'rules') => {
if (next === 'all') {
emit('update:modelValue', { mode: 'all', rules: [] });
return;
}
const collapsed = collapseRules(props.modelValue?.rules ?? []);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
});
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
if (next.pipelineIds?.length) cleaned.pipelineIds = [...next.pipelineIds];
if (next.boardIds?.length) cleaned.boardIds = [...next.boardIds];
if (next.typeIds?.length) cleaned.typeIds = [...next.typeIds];
emit('update:modelValue', { mode: 'rules', rules: [cleaned] });
}
function patchFilter(patch: Partial<DataAccessRule>) {
emitFilter({ ...filter.value, ...patch });
}
function toggleOwner(kind: DataAccessOwnerKind) {
const current = new Set(filter.value.owners ?? []);
if (current.has(kind)) current.delete(kind);
else current.add(kind);
const owners = [...current] as DataAccessOwnerKind[];
patchFilter({ owners: owners.length ? owners : undefined });
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
| 'pipelineIds'
| 'boardIds'
| 'typeIds';
type PickerMode = 'multiple' | 'tags';
type Picker = {
key: ListKey;
legend: string;
mode: PickerMode;
placeholder: string;
notFound: string;
hint?: string;
options: Choice[];
loading?: boolean;
};
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);
}
const catalogSources = new Set<string>(PERSON_IMPORT_SOURCES);
/** کلیدهای ثابت کاتالوگ که در این محیط رکورد ندارند را نشان نده؛ متن سفارشی بماند. */
function sourcesInThisEnvironment(selected: readonly string[] | undefined): string[] {
const list = selected ?? [];
if (!props.importSourcesReady) {
return list.filter((source) => !catalogSources.has(source));
}
const present = new Set(props.importSources.map((option) => option.value));
return list.filter((source) => present.has(source) || !catalogSources.has(source));
}
const visibleImportSources = computed(() => sourcesInThisEnvironment(filter.value.sources));
function setList(key: ListKey, value: unknown) {
const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
let list = [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
if (key === 'sources' && showImportSources.value && props.importSourcesReady) {
list = sourcesInThisEnvironment(list);
}
patchFilter({ [key]: list.length ? list : undefined });
}
function pickerValue(picker: Picker): string[] {
if (picker.key === 'sources' && showImportSources.value && props.importSourcesReady) {
return visibleImportSources.value;
}
return listValue(picker.key);
}
watch(visibleImportSources, (next) => {
if (!showImportSources.value || !props.importSourcesReady) return;
const current = filter.value.sources ?? [];
if (current.length === next.length && current.every((source, index) => source === next[index])) {
return;
}
patchFilter({ sources: next.length ? next : undefined });
});
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) {
const ready = props.importSourcesReady;
const failed = props.importSourcesFailed;
items.push({
key: 'sources',
legend: 'منبع',
mode: 'tags',
placeholder: failed
? 'فهرست منبع‌ها بارگذاری نشد'
: props.importSources.length
? 'جستجو، انتخاب یا نوشتن منبع جدید…'
: 'منبعی در این محیط نیست',
notFound: 'برای منبع جدید Enter بزنید',
hint: failed
? 'فهرست منبع‌های این محیط خوانده نشد؛ انتخاب ذخیره‌شده دست نخورده است.'
: 'فقط منبع‌هایی که در مشتریان این محیط هستند. نام تازه را بنویسید و Enter بزنید.',
loading: !ready && !failed,
options: withSelected(
ready ? props.importSources : [],
ready ? visibleImportSources.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 filterSummary = computed(() => {
const rule = filter.value;
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
if (rule.stageIds?.length) parts.push(`${rule.stageIds.length} مرحله`);
if (rule.boardIds?.length) parts.push(`${rule.boardIds.length} بورد`);
if (rule.typeIds?.length) parts.push(`${rule.typeIds.length} نوع/دسته`);
return parts.length ? parts.join(' · ') : 'هنوز فیلتری انتخاب نشده';
});
</script>
<template>
<div class="dae" :class="{ compact }">
<div class="dae-mode">
<label>
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
همهٔ {{ sectionLabel }}
</label>
<label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
<fieldset :disabled="disabled">
<legend>مالک / مسئول</legend>
<p class="muted">خالی = بدون محدودیت مالک</p>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('self')"
@change="toggleOwner('self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')"
@change="toggleOwner('team')"
/>
تیم‌های عضو
</label>
</fieldset>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<a-select
v-else
:value="pickerValue(picker)"
: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">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p>
</div>
</template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.pick {
display: grid;
gap: 0.35rem;
}
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;
}
</style>