Track person import source and previous names; add source report and edit UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 12:01:21 +03:30
co-authored by Cursor
parent e144b2277a
commit 8b37525f2f
15 changed files with 632 additions and 25 deletions
+118 -13
View File
@@ -25,9 +25,15 @@ import type {
ImportContactsResult,
PaginatedResponse,
PersonDto,
PersonImportSource,
PersonImportSourceStatDto,
PersonMood,
} from '@mento/shared';
import { brand } from '@mento/shared';
import {
brand,
PERSON_IMPORT_SOURCE_LABELS,
PERSON_IMPORT_SOURCES,
} from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
type Tab = 'people' | 'companies';
@@ -50,6 +56,12 @@ const moodFilter = computed<PersonMood | 'all'>(() => {
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
});
const sourceFilter = computed<PersonImportSource | 'all'>(() => {
const value = typeof route.query.source === 'string' ? route.query.source : 'all';
return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource)
? (value as PersonImportSource)
: 'all';
});
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
@@ -59,6 +71,7 @@ const error = ref('');
const importMsg = ref('');
const people = ref<PaginatedResponse<PersonDto> | null>(null);
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
const sourceStats = ref<PersonImportSourceStatDto[]>([]);
const menuId = ref<string | null>(null);
const sortOpen = ref(false);
const filterOpen = ref(false);
@@ -73,6 +86,7 @@ const peopleSavedFilters = computed(() => ({
q: q.value.trim() || undefined,
sort: sort.value === 'name' ? undefined : sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
}));
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
@@ -84,15 +98,28 @@ function applyPeopleSavedFilters(filters: Record<string, unknown>) {
PERSON_MOODS.includes(filters.mood as PersonMood)
? (filters.mood as PersonMood)
: 'all';
const nextSource =
typeof filters.source === 'string' &&
PERSON_IMPORT_SOURCES.includes(filters.source as PersonImportSource)
? (filters.source as PersonImportSource)
: 'all';
patchQuery({
q: nextQ.trim() || undefined,
sort: nextSort === 'name' ? undefined : nextSort,
mood: nextMood === 'all' ? undefined : nextMood,
source: nextSource === 'all' ? undefined : nextSource,
page: undefined,
tab: undefined,
});
}
function sourceLabel(source: string) {
if (source in PERSON_IMPORT_SOURCE_LABELS) {
return PERSON_IMPORT_SOURCE_LABELS[source as PersonImportSource];
}
return source || 'دستی';
}
function moodOf(person: PersonDto): PersonMood {
return person.mood || 'newcomer';
}
@@ -103,6 +130,7 @@ function moodLabel(mood: PersonMood) {
/** کد شخص در نرم‌افزار مبدأ (دیدار و مشابه) */
function externalCode(person: PersonDto): string {
if (person.sourceRef?.trim()) return person.sourceRef.trim();
const fields = person.customFields ?? {};
for (const key of ['didarId', 'customerCode'] as const) {
const raw = fields[key];
@@ -137,16 +165,24 @@ async function load() {
closePopups();
try {
if (tab.value === 'people') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
sort: sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
},
});
people.value = data;
const [peopleRes, statsRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
sort: sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
importSource:
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
},
}),
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
data: [] as PersonImportSourceStatDto[],
})),
]);
people.value = peopleRes.data;
sourceStats.value = statsRes.data;
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { q: q.value || undefined, page: page.value, limit: PAGE_SIZE },
@@ -194,6 +230,13 @@ function setMood(next: PersonMood | 'all') {
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
}
function setSource(next: PersonImportSource | 'all') {
patchQuery({
source: next === 'all' ? undefined : next,
page: undefined,
});
}
function setPage(next: number) {
if (next < 1 || next > pageCount.value) return;
patchQuery({ page: next === 1 ? undefined : String(next) });
@@ -422,6 +465,28 @@ onMounted(load);
@apply="applyPeopleSavedFilters"
/>
<div v-if="tab === 'people' && sourceStats.length" class="source-stats">
<button
type="button"
class="source-chip"
:class="{ on: sourceFilter === 'all' }"
@click="setSource('all')"
>
همه منابع
</button>
<button
v-for="row in sourceStats"
:key="row.source"
type="button"
class="source-chip"
:class="{ on: sourceFilter === row.source }"
@click="setSource(row.source as PersonImportSource)"
>
{{ row.label }}
<em>{{ toFaDigits(row.count) }}</em>
</button>
</div>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
@@ -460,8 +525,12 @@ onMounted(load);
<p class="role">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
</p>
<p v-if="externalCode(p)" class="line">
<span>کد مبدأ: {{ externalCodeLabel(p) }}</span>
<p class="line">
<span>منبع: {{ sourceLabel(p.importSource || 'manual') }}</span>
<span v-if="externalCode(p)"> · کد: {{ externalCodeLabel(p) }}</span>
</p>
<p v-if="p.previousFullName" class="line muted">
نام قبلی: {{ p.previousFullName }}
</p>
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
<UiIcon name="phone" :size="13" />
@@ -482,6 +551,7 @@ onMounted(load);
<tr>
<th>وضعیت</th>
<th>نام شخص</th>
<th>منبع</th>
<th>کد مبدأ</th>
<th>سمت</th>
<th>شرکت</th>
@@ -504,7 +574,11 @@ onMounted(load);
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<div v-if="p.previousFullName" class="muted tiny">
قبلی: {{ p.previousFullName }}
</div>
</td>
<td>{{ sourceLabel(p.importSource || 'manual') }}</td>
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
<td>{{ p.position || '—' }}</td>
<td>
@@ -752,6 +826,37 @@ h1 {
color: #94a3b8;
}
.source-stats {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--color-border, #ddd);
background: var(--color-surface, #fff);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.82rem;
cursor: pointer;
}
.source-chip.on {
border-color: var(--color-primary, #2563eb);
color: var(--color-primary, #2563eb);
background: color-mix(in srgb, var(--color-primary, #2563eb) 10%, transparent);
}
.source-chip em {
font-style: normal;
font-weight: 700;
}
.tiny {
font-size: 0.75rem;
margin-top: 0.15rem;
}
.toolbar {
display: flex;
gap: 0.55rem;