Filtering tasks and assigning a person in charge during Excel import
This commit is contained in:
@@ -63,6 +63,11 @@ export class ActivitiesController {
|
||||
to: query.to,
|
||||
},
|
||||
query.desk,
|
||||
{
|
||||
type: query.type,
|
||||
status: query.status,
|
||||
assigneeId: query.assigneeId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,11 @@ export class ActivitiesService {
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: {
|
||||
type?: ActivityDto['type'];
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
},
|
||||
): Promise<PaginatedResponse<ActivityDto>> {
|
||||
const where = this.buildWhere(
|
||||
organizationId,
|
||||
@@ -138,6 +143,7 @@ export class ActivitiesService {
|
||||
entity,
|
||||
range,
|
||||
desk,
|
||||
facets,
|
||||
);
|
||||
const take = Math.min(Math.max(limit, 1), 200);
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
@@ -327,13 +333,24 @@ export class ActivitiesService {
|
||||
to?: string;
|
||||
},
|
||||
desk?: 'mine' | 'team',
|
||||
facets?: {
|
||||
type?: ActivityDto['type'];
|
||||
status?: ActivityDto['status'];
|
||||
assigneeId?: string;
|
||||
},
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope =
|
||||
desk === 'mine' ? { assigneeId: actor.userId } : assigneeWhere(actor);
|
||||
const facetWhere: Prisma.ActivityWhereInput = {
|
||||
...(facets?.type ? { type: facets.type } : {}),
|
||||
...(facets?.status ? { status: facets.status } : {}),
|
||||
...(facets?.assigneeId?.trim() ? { assigneeId: facets.assigneeId.trim() } : {}),
|
||||
};
|
||||
const base: Prisma.ActivityWhereInput = {
|
||||
organizationId,
|
||||
...scope,
|
||||
...facetWhere,
|
||||
...(entity?.personId ? { personId: entity.personId } : {}),
|
||||
...(entity?.companyId ? { companyId: entity.companyId } : {}),
|
||||
...(entity?.dealId ? { dealId: entity.dealId } : {}),
|
||||
@@ -370,19 +387,23 @@ export class ActivitiesService {
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit status facet overrides the status implied by time tabs.
|
||||
const impliedOpen = facets?.status ? { status: facets.status } : { status: 'open' as const };
|
||||
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
|
||||
|
||||
switch (filter) {
|
||||
case 'today':
|
||||
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
|
||||
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { ...base, status: 'open', dueAt: { lt: start } };
|
||||
return { ...base, ...impliedOpen, dueAt: { lt: start } };
|
||||
case 'upcoming':
|
||||
return {
|
||||
...base,
|
||||
status: 'open',
|
||||
...impliedOpen,
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
};
|
||||
case 'done':
|
||||
return { ...base, status: 'done' };
|
||||
return { ...base, ...impliedDone };
|
||||
case 'all':
|
||||
default:
|
||||
return base;
|
||||
|
||||
@@ -32,6 +32,18 @@ export class ListActivitiesQueryDto {
|
||||
@IsIn(['mine', 'team'])
|
||||
desk?: 'mine' | 'team';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ImportContactsResult, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere } from '../common/record-scope';
|
||||
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import {
|
||||
formatJalaliDate,
|
||||
parseExcelMapping,
|
||||
@@ -99,6 +99,12 @@ const PERSON_ALIASES: Record<string, string> = {
|
||||
تلفن2: 'phone2',
|
||||
تلفنهایدیگرمشتری: 'otherPhones',
|
||||
مسئولمشتری: 'ownerName',
|
||||
مسئول: 'ownerName',
|
||||
مسئولفروش: 'ownerName',
|
||||
کارشناسفروش: 'ownerName',
|
||||
کارشناس: 'ownerName',
|
||||
owner: 'ownerName',
|
||||
ownername: 'ownerName',
|
||||
تلفنثابتشرکت: 'companyPhone',
|
||||
کددیدارشرکت: 'companyDidarId',
|
||||
مجوزمشاهدهمشتری: 'visibility',
|
||||
@@ -114,11 +120,14 @@ const PERSON_FIELDS = [
|
||||
{ key: 'firstName', label: 'نام' },
|
||||
{ key: 'lastName', label: 'نام خانوادگی' },
|
||||
{ key: 'title', label: 'نام کامل / عنوان' },
|
||||
{ key: 'didarId', label: 'کد مبدأ (کد دیدار)' },
|
||||
{ key: 'customerCode', label: 'کد مشتری' },
|
||||
{ key: 'mobile', label: 'موبایل' },
|
||||
{ key: 'phone', label: 'تلفن' },
|
||||
{ key: 'email', label: 'ایمیل' },
|
||||
{ key: 'position', label: 'سمت' },
|
||||
{ key: 'companyName', label: 'شرکت' },
|
||||
{ key: 'ownerName', label: 'کارشناس فروش' },
|
||||
{ key: 'notes', label: 'یادداشت' },
|
||||
{ key: 'mood', label: 'وضعیت مشتری' },
|
||||
];
|
||||
@@ -335,6 +344,7 @@ export class ContactsExcelService {
|
||||
): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const ownerCache = new Map<string, string | null>();
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
@@ -363,6 +373,19 @@ export class ContactsExcelService {
|
||||
companyId = await this.findOrCreateCompanyId(actor, companyName);
|
||||
}
|
||||
|
||||
const ownerName = row.ownerName?.trim() ?? '';
|
||||
let ownerId: string | undefined;
|
||||
let ownerMissing = false;
|
||||
if (ownerName) {
|
||||
const key = ownerName.toLowerCase();
|
||||
if (!ownerCache.has(key)) {
|
||||
ownerCache.set(key, await this.findUserId(actor.organizationId, ownerName));
|
||||
}
|
||||
const matched = ownerCache.get(key);
|
||||
if (matched) ownerId = matched;
|
||||
else ownerMissing = true;
|
||||
}
|
||||
|
||||
const customFields: Record<string, string> = {};
|
||||
for (const key of CUSTOM_KEYS) {
|
||||
const value = row[key]?.trim();
|
||||
@@ -380,9 +403,16 @@ export class ContactsExcelService {
|
||||
notes: row.notes || undefined,
|
||||
mood: parseMood(row.mood) ?? parseMood(row.group),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, ownerId ?? null),
|
||||
customFields,
|
||||
});
|
||||
result.created += 1;
|
||||
if (ownerMissing) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: `کارشناس فروش «${ownerName}» در تیم پیدا نشد؛ به کاربر ایمپورتر اختصاص داده شد`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ConflictException) {
|
||||
result.skipped += 1;
|
||||
@@ -453,4 +483,25 @@ export class ContactsExcelService {
|
||||
const created = await this.companies.create(actor, { name });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const exact = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
fullName: { equals: n, mode: 'insensitive' },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (exact) return exact.id;
|
||||
const partial = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
fullName: { contains: n, mode: 'insensitive' },
|
||||
memberships: { some: { organizationId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return partial?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivityStatus,
|
||||
ActivityType,
|
||||
ImportContactsResult,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
@@ -32,6 +34,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { canViewAllRecords } from '@/lib/roles';
|
||||
|
||||
type TypeFilter = 'all' | ActivityType;
|
||||
type StatusFilter = 'all' | ActivityStatus;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
|
||||
@@ -56,6 +59,13 @@ const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
|
||||
{ key: 'note', label: 'پیام' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه وضعیتها' },
|
||||
{ key: 'open', label: 'باز / انجامنشده' },
|
||||
{ key: 'done', label: 'انجامشده' },
|
||||
{ key: 'canceled', label: 'لغو شده' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
const route = useRoute();
|
||||
@@ -85,9 +95,28 @@ const loading = ref(false);
|
||||
const error = ref('');
|
||||
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const typeFilter = ref<TypeFilter>('all');
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const filterOpen = ref(false);
|
||||
const ideasOpen = ref(false);
|
||||
|
||||
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
const typeFilter = computed<TypeFilter>(() => {
|
||||
const value = typeof route.query.type === 'string' ? route.query.type : 'all';
|
||||
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all';
|
||||
});
|
||||
|
||||
const statusFilter = computed<StatusFilter>(() => {
|
||||
const value = typeof route.query.status === 'string' ? route.query.status : 'all';
|
||||
return ACTIVITY_STATUSES.includes(value as ActivityStatus)
|
||||
? (value as ActivityStatus)
|
||||
: 'all';
|
||||
});
|
||||
|
||||
const assigneeFilter = computed(() =>
|
||||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||||
);
|
||||
const activityModal = ref<{
|
||||
type?: ActivityType;
|
||||
title?: string;
|
||||
@@ -133,15 +162,21 @@ const statusIcon: Record<UiStatus, 'check' | 'clock' | 'calendar' | 'warning'> =
|
||||
canceled: 'warning',
|
||||
};
|
||||
|
||||
const items = computed(() => {
|
||||
const rows = data.value?.items ?? [];
|
||||
if (typeFilter.value === 'all') return rows;
|
||||
return rows.filter((row) => row.type === typeFilter.value);
|
||||
});
|
||||
const items = computed(() => data.value?.items ?? []);
|
||||
|
||||
const total = computed(() => data.value?.total ?? 0);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
|
||||
const activeFacetCount = computed(() => {
|
||||
let n = 0;
|
||||
if (typeFilter.value !== 'all') n += 1;
|
||||
if (statusFilter.value !== 'all') n += 1;
|
||||
if (assigneeFilter.value) n += 1;
|
||||
return n;
|
||||
});
|
||||
const filterBtnLabel = computed(() =>
|
||||
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
|
||||
);
|
||||
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
|
||||
@@ -208,6 +243,19 @@ async function loadCounts() {
|
||||
counts.value = next;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!showDeskTabs.value) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data: rows } = await api.get<OrgUserDto[]>('/users');
|
||||
users.value = rows.filter((u) => u.isActive !== false);
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -220,6 +268,9 @@ async function load() {
|
||||
page: page.value,
|
||||
limit: PAGE_SIZE,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
@@ -279,9 +330,48 @@ function onSearch() {
|
||||
});
|
||||
}
|
||||
|
||||
function patchFacetQuery(patch: Record<string, string | undefined>) {
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
...patch,
|
||||
page: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setTypeFilter(next: TypeFilter) {
|
||||
typeFilter.value = next;
|
||||
filterOpen.value = false;
|
||||
patchFacetQuery({ type: next === 'all' ? undefined : next });
|
||||
}
|
||||
|
||||
function setStatusFilter(next: StatusFilter) {
|
||||
// Avoid empty results when a time-tab forces a different status.
|
||||
const nextFilter =
|
||||
next === 'all'
|
||||
? route.query.filter
|
||||
: next === 'done'
|
||||
? 'done'
|
||||
: 'all';
|
||||
patchFacetQuery({
|
||||
status: next === 'all' ? undefined : next,
|
||||
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
|
||||
});
|
||||
}
|
||||
|
||||
function setAssigneeFilter(next: string) {
|
||||
patchFacetQuery({
|
||||
assigneeId: next || undefined,
|
||||
desk: next && showDeskTabs.value ? 'team' : route.query.desk === 'team' ? 'team' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function clearFacets() {
|
||||
patchFacetQuery({
|
||||
type: undefined,
|
||||
status: undefined,
|
||||
assigneeId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function complete(id: string) {
|
||||
@@ -346,7 +436,15 @@ function onImportSettled() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.filter, route.query.q, route.query.page, route.query.desk],
|
||||
() => [
|
||||
route.query.filter,
|
||||
route.query.q,
|
||||
route.query.page,
|
||||
route.query.desk,
|
||||
route.query.type,
|
||||
route.query.status,
|
||||
route.query.assigneeId,
|
||||
],
|
||||
() => {
|
||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||
void load();
|
||||
@@ -363,6 +461,7 @@ onMounted(() => {
|
||||
mobileQuery = window.matchMedia('(max-width: 899px)');
|
||||
syncMobile();
|
||||
mobileQuery.addEventListener('change', syncMobile);
|
||||
void loadUsers();
|
||||
void load();
|
||||
void loadCounts();
|
||||
});
|
||||
@@ -455,21 +554,67 @@ onUnmounted(() => {
|
||||
/>
|
||||
</form>
|
||||
<div class="filter-wrap" @click.stop>
|
||||
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
|
||||
<button
|
||||
class="btn ghost filter-btn"
|
||||
type="button"
|
||||
:class="{ on: activeFacetCount > 0 }"
|
||||
@click="filterOpen = !filterOpen"
|
||||
>
|
||||
<UiIcon name="filter" :size="16" />
|
||||
فیلتر
|
||||
{{ filterBtnLabel }}
|
||||
<UiIcon name="chevron" :size="14" class="chev" />
|
||||
</button>
|
||||
<div v-if="filterOpen" class="popover">
|
||||
<div v-if="filterOpen" class="popover filter-popover">
|
||||
<p class="facet-title">نوع کار</p>
|
||||
<button
|
||||
v-for="opt in TYPE_FILTERS"
|
||||
:key="opt.key"
|
||||
:key="`type-${opt.key}`"
|
||||
type="button"
|
||||
:class="{ on: typeFilter === opt.key }"
|
||||
@click="setTypeFilter(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
|
||||
<p class="facet-title">وضعیت</p>
|
||||
<button
|
||||
v-for="opt in STATUS_FILTERS"
|
||||
:key="`status-${opt.key}`"
|
||||
type="button"
|
||||
:class="{ on: statusFilter === opt.key }"
|
||||
@click="setStatusFilter(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
|
||||
<template v-if="showDeskTabs">
|
||||
<p class="facet-title">کارشناس</p>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: !assigneeFilter }"
|
||||
@click="setAssigneeFilter('')"
|
||||
>
|
||||
همه کارشناسان
|
||||
</button>
|
||||
<button
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
:class="{ on: assigneeFilter === user.id }"
|
||||
@click="setAssigneeFilter(user.id)"
|
||||
>
|
||||
{{ user.fullName }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="activeFacetCount"
|
||||
type="button"
|
||||
class="clear-facets"
|
||||
@click="clearFacets"
|
||||
>
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -916,6 +1061,12 @@ h1 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.filter-btn.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
@@ -929,6 +1080,30 @@ h1 {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.filter-popover {
|
||||
min-width: 14rem;
|
||||
max-height: min(70vh, 28rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.facet-title {
|
||||
margin: 0.45rem 0.35rem 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.facet-title:first-child {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.clear-facets {
|
||||
margin-top: 0.25rem;
|
||||
border-top: 1px solid #e2e8f0 !important;
|
||||
border-radius: 0 0 var(--radius) var(--radius) !important;
|
||||
color: #b91c1c !important;
|
||||
}
|
||||
|
||||
.popover a,
|
||||
.popover button {
|
||||
display: block;
|
||||
|
||||
@@ -98,6 +98,22 @@ function moodLabel(mood: PersonMood) {
|
||||
return PERSON_MOOD_LABELS[mood];
|
||||
}
|
||||
|
||||
/** کد شخص در نرمافزار مبدأ (دیدار و مشابه) */
|
||||
function externalCode(person: PersonDto): string {
|
||||
const fields = person.customFields ?? {};
|
||||
for (const key of ['didarId', 'customerCode'] as const) {
|
||||
const raw = fields[key];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function externalCodeLabel(person: PersonDto): string {
|
||||
const code = externalCode(person);
|
||||
return code ? toFaDigits(code) : '—';
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
@@ -430,6 +446,9 @@ 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>
|
||||
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
|
||||
<UiIcon name="phone" :size="13" />
|
||||
<span>{{ p.mobile || p.phone }}</span>
|
||||
@@ -449,6 +468,7 @@ onMounted(load);
|
||||
<tr>
|
||||
<th>وضعیت</th>
|
||||
<th>نام شخص</th>
|
||||
<th>کد مبدأ</th>
|
||||
<th>سمت</th>
|
||||
<th>شرکت</th>
|
||||
<th>تلفن</th>
|
||||
@@ -471,6 +491,7 @@ onMounted(load);
|
||||
{{ p.fullName }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
|
||||
<td>{{ p.position || '—' }}</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
|
||||
@@ -137,6 +137,15 @@ const mailHref = computed(() =>
|
||||
const registeredAt = computed(() =>
|
||||
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
|
||||
);
|
||||
const externalCode = computed(() => {
|
||||
const fields = person.value?.customFields ?? {};
|
||||
for (const key of ['didarId', 'customerCode'] as const) {
|
||||
const raw = fields[key];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const lastCallAt = computed(() => {
|
||||
const calls = activities.value
|
||||
@@ -334,6 +343,7 @@ const infoEmpty = computed(() => {
|
||||
!p.website &&
|
||||
!p.referrer &&
|
||||
!p.company &&
|
||||
!externalCode.value &&
|
||||
!p.notes?.trim() &&
|
||||
!tickets.value.length
|
||||
);
|
||||
@@ -618,6 +628,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</div>
|
||||
<dl class="facts">
|
||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
||||
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
||||
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
|
||||
@@ -893,6 +904,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
|
||||
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
|
||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
||||
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
||||
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
|
||||
|
||||
Reference in New Issue
Block a user