From 989c03b3819696f50eac6456a3bb2fb1667baf25 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 3 Oct 2026 12:00:46 +0330 Subject: [PATCH] Filtering tasks and assigning a person in charge during Excel import --- .../src/activities/activities.controller.ts | 5 + apps/api/src/activities/activities.service.ts | 29 ++- apps/api/src/activities/dto.ts | 12 ++ .../src/contacts/contacts-excel.service.ts | 53 ++++- apps/web/src/views/ActivitiesView.vue | 201 ++++++++++++++++-- apps/web/src/views/ContactsView.vue | 21 ++ apps/web/src/views/PersonProfileView.vue | 12 ++ 7 files changed, 315 insertions(+), 18 deletions(-) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index a4312f3..7ccea97 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -63,6 +63,11 @@ export class ActivitiesController { to: query.to, }, query.desk, + { + type: query.type, + status: query.status, + assigneeId: query.assigneeId, + }, ); } diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 41d493e..9abb672 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -129,6 +129,11 @@ export class ActivitiesService { to?: string; }, desk?: 'mine' | 'team', + facets?: { + type?: ActivityDto['type']; + status?: ActivityDto['status']; + assigneeId?: string; + }, ): Promise> { 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; diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 1678a1a..0076529 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -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; diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 106f5aa..7896fbf 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -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 = { تلفن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 { const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw)); const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] }; + const ownerCache = new Map(); 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 = {}; 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; + } } diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 0caa64e..3e1aaa5 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -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 | null>(null); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); -const typeFilter = ref('all'); +const users = ref([]); 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(() => { + 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(() => { + 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 = 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('/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) { + 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(() => { />
- -
+
+

نوع کار

+ +

وضعیت

+ + + + +
@@ -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; diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 7c2e449..66da2b6 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -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.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}

+

+ کد مبدأ: {{ externalCodeLabel(p) }} +

{{ p.mobile || p.phone }} @@ -449,6 +468,7 @@ onMounted(load); وضعیت نام شخص + کد مبدأ سمت شرکت تلفن @@ -471,6 +491,7 @@ onMounted(load); {{ p.fullName }} + {{ externalCodeLabel(p) }} {{ p.position || '—' }} 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 });

سمت
{{ person.position || '—' }}
+
کد مبدأ
{{ externalCode ? toFaDigits(externalCode) : '—' }}
وب‌سایت
{{ person.website || '—' }}
معرف
{{ person.referrer || '—' }}
شرکت
{{ person.company?.name || '—' }}
@@ -893,6 +904,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
تلفن
{{ person.phone ? toFaDigits(person.phone) : '—' }}
ایمیل
{{ person.email || '—' }}
سمت
{{ person.position || '—' }}
+
کد مبدأ
{{ externalCode ? toFaDigits(externalCode) : '—' }}
وب‌سایت
{{ person.website || '—' }}
معرف
{{ person.referrer || '—' }}
شرکت
{{ person.company?.name || '—' }}