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:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user