Filtering tasks and assigning a person in charge during Excel import

This commit is contained in:
2026-10-03 12:00:46 +03:30
parent 5784b09232
commit 989c03b381
7 changed files with 315 additions and 18 deletions
@@ -63,6 +63,11 @@ export class ActivitiesController {
to: query.to,
},
query.desk,
{
type: query.type,
status: query.status,
assigneeId: query.assigneeId,
},
);
}
+25 -4
View File
@@ -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;
+12
View File
@@ -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;
}
}
+188 -13
View File
@@ -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;
+21
View File
@@ -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
+12
View File
@@ -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>