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;
}
}