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>
This commit is contained in:
2026-10-25 19:22:30 +03:30
co-authored by Cursor
parent 91637cb3a6
commit 0daaef0b39
4 changed files with 94 additions and 21 deletions
+61 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, watch } from 'vue';
import { Select as ASelect } from 'ant-design-vue'; import { Select as ASelect } from 'ant-design-vue';
import type { import type {
DataAccessOwnerKind, DataAccessOwnerKind,
@@ -10,13 +10,13 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { import {
DATA_ACCESS_SECTION_LABELS, DATA_ACCESS_SECTION_LABELS,
PERSON_IMPORT_SOURCE_LABELS,
PERSON_IMPORT_SOURCES, PERSON_IMPORT_SOURCES,
PERSON_MOOD_LABELS, PERSON_MOOD_LABELS,
PERSON_MOODS, PERSON_MOODS,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
export type IdOption = { id: string; name: string }; export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string };
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -27,6 +27,10 @@ const props = withDefaults(
pipelines?: IdOption[]; pipelines?: IdOption[];
boards?: IdOption[]; boards?: IdOption[];
activityTypes?: IdOption[]; activityTypes?: IdOption[];
/** منبع‌هایی که در مشتریان همین محیط رکورد دارند */
importSources?: Choice[];
importSourcesReady?: boolean;
importSourcesFailed?: boolean;
disabled?: boolean; disabled?: boolean;
compact?: boolean; compact?: boolean;
}>(), }>(),
@@ -37,6 +41,9 @@ const props = withDefaults(
pipelines: () => [], pipelines: () => [],
boards: () => [], boards: () => [],
activityTypes: () => [], activityTypes: () => [],
importSources: () => [],
importSourcesReady: false,
importSourcesFailed: false,
}, },
); );
@@ -211,7 +218,6 @@ type ListKey =
| 'boardIds' | 'boardIds'
| 'typeIds'; | 'typeIds';
type Choice = { value: string; label: string };
type PickerMode = 'multiple' | 'tags'; type PickerMode = 'multiple' | 'tags';
type Picker = { type Picker = {
key: ListKey; key: ListKey;
@@ -221,6 +227,7 @@ type Picker = {
notFound: string; notFound: string;
hint?: string; hint?: string;
options: Choice[]; options: Choice[];
loading?: boolean;
}; };
function namedOptions(items: IdOption[]): Choice[] { function namedOptions(items: IdOption[]): Choice[] {
@@ -239,18 +246,44 @@ function listValue(key: ListKey): string[] {
return ((filter.value[key] as string[] | undefined) ?? []).map(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) { function setList(key: ListKey, value: unknown) {
const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]; const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
const list = [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))]; 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 }); patchFilter({ [key]: list.length ? list : undefined });
} }
const sourceOptions = computed(() => function pickerValue(picker: Picker): string[] {
PERSON_IMPORT_SOURCES.map((s) => ({ if (picker.key === 'sources' && showImportSources.value && props.importSourcesReady) {
value: s, return visibleImportSources.value;
label: PERSON_IMPORT_SOURCE_LABELS[s], }
})), 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) => ({ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
value: mood, value: mood,
@@ -271,14 +304,26 @@ const pickers = computed((): Picker[] => {
}); });
} }
if (showImportSources.value) { if (showImportSources.value) {
const ready = props.importSourcesReady;
const failed = props.importSourcesFailed;
items.push({ items.push({
key: 'sources', key: 'sources',
legend: 'منبع', legend: 'منبع',
mode: 'tags', mode: 'tags',
placeholder: 'جستجو، انتخاب یا نوشتن منبع جدید…', placeholder: failed
? 'فهرست منبع‌ها بارگذاری نشد'
: props.importSources.length
? 'جستجو، انتخاب یا نوشتن منبع جدید…'
: 'منبعی در این محیط نیست',
notFound: 'برای منبع جدید Enter بزنید', notFound: 'برای منبع جدید Enter بزنید',
hint: 'چند منبع را با هم انتخاب کنید. نام تازه را بنویسید و Enter بزنید.', hint: failed
options: withSelected(sourceOptions.value, rule.sources), ? 'فهرست منبع‌های این محیط خوانده نشد؛ انتخاب ذخیره‌شده دست نخورده است.'
: 'فقط منبع‌هایی که در مشتریان این محیط هستند. نام تازه را بنویسید و Enter بزنید.',
loading: !ready && !failed,
options: withSelected(
ready ? props.importSources : [],
ready ? visibleImportSources.value : (rule.sources ?? []),
),
}); });
} }
if (showLeadSources.value) { if (showLeadSources.value) {
@@ -430,8 +475,10 @@ const filterSummary = computed(() => {
<label v-for="picker in pickers" :key="picker.legend" class="pick"> <label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span> <span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<a-select <a-select
:value="listValue(picker.key)" v-else
:value="pickerValue(picker)"
:mode="picker.mode" :mode="picker.mode"
allow-clear allow-clear
show-search show-search
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import type { import type {
DataAccessPolicy, DataAccessPolicy,
DataAccessSection, DataAccessSection,
PersonImportSourceStatDto,
RolePermissionKey, RolePermissionKey,
SectionDataAccess, SectionDataAccess,
} from '@mento/shared'; } from '@mento/shared';
@@ -76,6 +77,9 @@ function setSection(key: DataAccessSection, next: SectionDataAccess) {
} }
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups(); const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false);
const importSourcesFailed = ref(false);
const leadStages = ref<IdOption[]>([]); const leadStages = ref<IdOption[]>([]);
const pipelines = ref<IdOption[]>([]); const pipelines = ref<IdOption[]>([]);
const dealStages = ref<IdOption[]>([]); const dealStages = ref<IdOption[]>([]);
@@ -88,6 +92,13 @@ const routineCategories = ref<IdOption[]>([]);
async function loadOptions() { async function loadOptions() {
await loadCustomerGroups().catch(() => undefined); await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
importSourcesReady.value = true;
} catch {
importSourcesFailed.value = true;
}
try { try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages'); const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages');
leadStages.value = data.map((s) => ({ id: s.id, name: s.name })); leadStages.value = data.map((s) => ({ id: s.id, name: s.name }));
@@ -217,6 +228,9 @@ const typesForActive = computed(() => {
:pipelines="pipelines" :pipelines="pipelines"
:boards="boardsForActive" :boards="boardsForActive"
:activity-types="typesForActive" :activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled" :disabled="disabled"
:compact="compact" :compact="compact"
@update:model-value="setSection(active, $event)" @update:model-value="setSection(active, $event)"
@@ -63,10 +63,14 @@ const shortcutLabel = computed(() =>
</script> </script>
<template> <template>
<header class="hero"> <header class="hero" style="position: relative">
<div class="hero-surface" aria-hidden="true"> <div
<div class="hero-glow" /> class="hero-surface"
<div class="hero-mist" /> aria-hidden="true"
style="position: absolute; inset: 0; overflow: hidden; pointer-events: none"
>
<div class="hero-glow" style="position: absolute" />
<div class="hero-mist" style="position: absolute; inset: 0" />
</div> </div>
<div class="hero-body"> <div class="hero-body">
+11 -3
View File
@@ -447,7 +447,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-col :span="24"> <a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM"> <a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-space :size="12" align="start"> <a-space :size="12" align="start">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }"> <RouterLink :to="{ name: 'leads-board' }" custom v-slot="{ navigate }">
<a-button @click="navigate"> <a-button @click="navigate">
<template #icon><ArrowLeftOutlined /></template> <template #icon><ArrowLeftOutlined /></template>
</a-button> </a-button>
@@ -696,7 +696,11 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div> <div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item v-if="lead.notes" label="یادداشت" :span="2"> <a-descriptions-item v-if="lead.notes" label="یادداشت" :span="2">
{{ lead.notes }} <a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="lead.notes"
style="margin-bottom: 0"
/>
</a-descriptions-item> </a-descriptions-item>
</a-descriptions> </a-descriptions>
</template> </template>
@@ -804,7 +808,11 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-col :xs="24" :lg="7" :xl="6"> <a-col :xs="24" :lg="7" :xl="6">
<a-affix v-if="!narrow" :offset-top="24"> <a-affix v-if="!narrow" :offset-top="24">
<a-card title="اقدامات سریع" :bordered="false"> <a-card
title="اقدامات سریع"
:bordered="false"
:body-style="{ maxHeight: 'calc(100vh - 140px)', overflowY: 'auto' }"
>
<a-form layout="vertical"> <a-form layout="vertical">
<a-divider orientation="right">پیامک</a-divider> <a-divider orientation="right">پیامک</a-divider>
<a-button <a-button