diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 424bce2..610ce96 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -193,6 +193,9 @@ export class ActivitiesController { typeDefId: query.typeDefId, status: query.status, assigneeId: query.assigneeId, + title: query.title, + customer: query.customer, + due: query.due, boardId: query.boardId, }, ); @@ -226,6 +229,9 @@ export class ActivitiesController { typeDefId: query.typeDefId, status: query.status, assigneeId: query.assigneeId, + title: query.title, + customer: query.customer, + due: query.due, boardId: query.boardId, }, query.stageId ? { stageId: query.stageId } : undefined, diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 2d757f2..c8d2796 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -53,6 +53,10 @@ type ListFacets = { typeDefId?: string; status?: ActivityDto['status']; assigneeId?: string; + title?: string; + customer?: string; + /** set = زمان شروع دارد، unset = ندارد */ + due?: 'set' | 'unset'; /** گروه فعالیت؛ خالی = همهٔ گروه‌ها */ boardId?: string; }; @@ -864,11 +868,15 @@ export class ActivitiesService { : activityAccessWhere(actor); const facetAssignee = facets?.assigneeId?.trim(); const facetTypeDef = facets?.typeDefId?.trim(); + const facetTitle = facets?.title?.trim(); + const facetCustomer = facets?.customer?.trim(); const andParts: Prisma.ActivityWhereInput[] = [ scope, ...(facets?.type ? [{ type: facets.type }] : []), ...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []), ...(facets?.status ? [{ status: facets.status }] : []), + ...(facetTitle ? [{ title: ci(facetTitle) }] : []), + ...(facetCustomer ? [this.customerWhere(facetCustomer)] : []), ...(facetAssignee ? [ { @@ -915,15 +923,18 @@ export class ActivitiesService { const fromDate = range?.from ? new Date(range.from) : null; const toDate = range?.to ? new Date(range.to) : null; if (fromDate || toDate) { - return { - ...base, - dueAt: { - ...(fromDate && !Number.isNaN(fromDate.getTime()) - ? { gte: fromDate } - : {}), - ...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}), + return this.withDue( + { + ...base, + dueAt: { + ...(fromDate && !Number.isNaN(fromDate.getTime()) + ? { gte: fromDate } + : {}), + ...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}), + }, }, - }; + facets?.due, + ); } // Explicit status facet overrides the status implied by time tabs. @@ -937,28 +948,62 @@ export class ActivitiesService { switch (filter) { case 'today': - return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } }; + return this.withDue( + { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } }, + facets?.due, + ); case 'overdue': // عقب‌افتاده ذاتاً یعنی باز و موعدگذشته - return { - ...base, - ...(facets?.status ? { status: facets.status } : { status: 'open' as const }), - dueAt: { lt: start }, - }; + return this.withDue( + { + ...base, + ...(facets?.status ? { status: facets.status } : { status: 'open' as const }), + dueAt: { lt: start }, + }, + facets?.due, + ); case 'upcoming': - return { - ...base, - ...impliedOpen, - OR: [{ dueAt: { gte: end } }, { dueAt: null }], - }; + return this.withDue( + { + ...base, + ...impliedOpen, + OR: [{ dueAt: { gte: end } }, { dueAt: null }], + }, + facets?.due, + ); case 'done': - return { ...base, ...impliedDone }; + return this.withDue({ ...base, ...impliedDone }, facets?.due); case 'all': default: - return base; + return this.withDue(base, facets?.due); } } + /** نام شخص (چندکلمه‌ای) یا نام شرکت */ + private customerWhere(term: string): Prisma.ActivityWhereInput { + const parts = term.split(/\s+/).filter(Boolean); + const person: Prisma.PersonWhereInput = + parts.length <= 1 + ? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] } + : { + AND: parts.map((part) => ({ + OR: [{ firstName: ci(part) }, { lastName: ci(part) }], + })), + }; + return { + OR: [{ person }, { company: { name: ci(term) } }], + }; + } + + /** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */ + private withDue( + where: Prisma.ActivityWhereInput, + due?: 'set' | 'unset', + ): Prisma.ActivityWhereInput { + if (due !== 'set' && due !== 'unset') return where; + return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] }; + } + private async fireDone( organizationId: string, row: { diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 38a8d2b..6bda448 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -69,6 +69,23 @@ export class ListActivitiesQueryDto { @IsString() assigneeId?: string; + /** فیلتر عنوان فعالیت، جدا از جستجوی کلی */ + @IsOptional() + @IsString() + @MaxLength(120) + title?: string; + + /** نام شخص یا شرکتِ مشتری */ + @IsOptional() + @IsString() + @MaxLength(120) + customer?: string; + + /** زمان شروع: set = دارد، unset = ندارد */ + @IsOptional() + @IsIn(['set', 'unset']) + due?: 'set' | 'unset'; + /** نوع فعالیت (تعریف‌شده در سازمان) */ @IsOptional() @IsString() diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index b3c7d7d..184aa6c 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -393,7 +393,7 @@ const route = useRoute(); function openActivity(id: string) { const query: Record = {}; - for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId']) { + for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due']) { const value = route.query[key]; if (typeof value === 'string' && value) query[key] = value; } diff --git a/apps/web/src/components/activities/ActivityDeskFilters.vue b/apps/web/src/components/activities/ActivityDeskFilters.vue new file mode 100644 index 0000000..fbaf69a --- /dev/null +++ b/apps/web/src/components/activities/ActivityDeskFilters.vue @@ -0,0 +1,492 @@ + + + + + diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index c23636e..653643c 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -21,6 +21,7 @@ import { import UiIcon from '@/components/UiIcon.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue'; +import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue'; import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue'; import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; @@ -62,9 +63,9 @@ const ACT_COLUMNS = [ { key: 'title', label: 'عنوان فعالیت', width: 'minmax(0, 2fr)' }, { key: 'who', label: 'کارشناس', width: 'minmax(7rem, 0.9fr)' }, { key: 'customer', label: 'مشتری', width: 'minmax(7rem, 1fr)' }, - { key: 'type', label: 'نوع فعالیت', width: '7.6rem' }, - { key: 'when', label: 'زمان شروع', width: '7.2rem' }, - { key: 'status', label: 'وضعیت', width: '8.6rem' }, + { key: 'type', label: 'نوع فعالیت', width: '9.2rem' }, + { key: 'when', label: 'زمان شروع', width: '8.6rem' }, + { key: 'status', label: 'وضعیت', width: '9.2rem' }, ] as const; type ActColKey = (typeof ACT_COLUMNS)[number]['key']; registerTableColumns( @@ -204,6 +205,7 @@ const data = ref | null>(null); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const users = ref([]); const filterOpen = ref(false); +const openCol = ref(null); const ideasOpen = ref(false); const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note']; @@ -240,6 +242,16 @@ const statusFilter = computed(() => { const assigneeFilter = computed(() => typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '', ); +const titleFilter = computed(() => + typeof route.query.title === 'string' ? route.query.title : '', +); +const customerFilter = computed(() => + typeof route.query.customer === 'string' ? route.query.customer : '', +); +const dueFilter = computed<'' | 'set' | 'unset'>(() => { + const value = route.query.due; + return value === 'set' || value === 'unset' ? value : ''; +}); const activityModal = ref<{ type?: ActivityType; title?: string; @@ -305,6 +317,9 @@ const sharedParams = computed>(() => ({ : undefined, status: statusFilter.value === 'all' ? undefined : statusFilter.value, assigneeId: assigneeFilter.value || undefined, + title: titleFilter.value.trim() || undefined, + customer: customerFilter.value.trim() || undefined, + due: dueFilter.value || undefined, })); function stageDotStyle(s: ActivityStageDto) { @@ -315,6 +330,9 @@ const activeFacetCount = computed(() => { if (typeFilter.value !== 'all') n += 1; if (statusFilter.value !== 'all') n += 1; if (assigneeFilter.value) n += 1; + if (titleFilter.value.trim()) n += 1; + if (customerFilter.value.trim()) n += 1; + if (dueFilter.value) n += 1; return n; }); const filterBtnLabel = computed(() => @@ -400,7 +418,7 @@ async function loadStages() { function activityRoute(id: string, edit = false) { const query: Record = {}; - for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId'] as const) { + for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) { const value = route.query[key]; if (typeof value === 'string' && value) query[key] = value; } @@ -721,8 +739,10 @@ function setTypeFilter(next: TypeFilter) { patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined }); } -function setStatusFilter(next: StatusFilter) { - patchFacetQuery({ status: next === 'all' ? undefined : next }); +function setStatusFilter(next: string) { + const value: StatusFilter = + next === 'open' || next === 'done' || next === 'canceled' ? next : 'all'; + patchFacetQuery({ status: value === 'all' ? undefined : value }); } function setAssigneeFilter(next: string) { @@ -732,14 +752,55 @@ function setAssigneeFilter(next: string) { }); } +function setTitleFilter(next: string) { + patchFacetQuery({ title: next.trim() || undefined }); +} + +function setCustomerFilter(next: string) { + patchFacetQuery({ customer: next.trim() || undefined }); +} + +function setDueFilter(next: '' | 'set' | 'unset') { + patchFacetQuery({ due: next || undefined }); +} + function clearFacets() { patchFacetQuery({ type: undefined, + typeDefId: undefined, status: undefined, assigneeId: undefined, + title: undefined, + customer: undefined, + due: undefined, }); } +function colFiltered(key: ActColKey) { + if (key === 'title') return Boolean(titleFilter.value.trim()); + if (key === 'who') return Boolean(assigneeFilter.value); + if (key === 'customer') return Boolean(customerFilter.value.trim()); + if (key === 'type') return typeFilter.value !== 'all'; + if (key === 'when') return Boolean(dueFilter.value); + if (key === 'status') return statusFilter.value !== 'all'; + return false; +} + +function toggleFilter() { + openCol.value = null; + filterOpen.value = !filterOpen.value; +} + +function toggleCol(key: ActColKey) { + if (key === 'who' && !showDeskTabs.value) return; + filterOpen.value = false; + openCol.value = openCol.value === key ? null : key; +} + +function onFacetDone() { + openCol.value = null; +} + async function complete(id: string) { menuId.value = null; boardMenuId.value = null; @@ -754,6 +815,7 @@ async function complete(id: string) { function closePopovers() { filterOpen.value = false; + openCol.value = null; menuId.value = null; } @@ -935,6 +997,9 @@ watch( route.query.typeDefId, route.query.status, route.query.assigneeId, + route.query.title, + route.query.customer, + route.query.due, route.query.stage, route.query.view, ], @@ -1286,63 +1351,33 @@ onUnmounted(() => { :class="{ on: activeFacetCount > 0 }" :title="filterBtnLabel" :aria-label="filterBtnLabel" - @click="filterOpen = !filterOpen" + @click="toggleFilter" > {{ filterBtnLabel }}
-

نوع کار

- - -

وضعیت

- - - - - +
@@ -1522,7 +1557,46 @@ onUnmounted(() => { # @@ -2500,9 +2574,10 @@ h1 { } .filter-popover { - min-width: 14rem; - max-height: min(70vh, 28rem); + width: min(20rem, calc(100vw - 1.5rem)); + max-height: min(70vh, 32rem); overflow: auto; + padding: 0.7rem 0.75rem 0.55rem; } .facet-title { @@ -2619,13 +2694,15 @@ h1 { .thead, .trow { display: grid; - grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 7.6rem 7.2rem 8.6rem 2.4rem; + grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 9.2rem 8.6rem 9.2rem 2.4rem; gap: 0.8rem; align-items: center; padding: 0.95rem 1.1rem; } .thead { + position: relative; + z-index: 3; color: #94a3b8; font-size: 0.78rem; font-weight: 600; @@ -2633,6 +2710,55 @@ h1 { border-radius: var(--radius) var(--radius) 0 0; } +.th-cell { + position: relative; + display: flex; + align-items: center; + gap: 0.2rem; + min-width: 0; +} + +.th-cell > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.th-filter { + flex: none; + display: grid; + place-items: center; + width: 1.35rem; + height: 1.35rem; + margin: 0; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #94a3b8; + cursor: pointer; +} + +.th-filter:hover, +.th-filter.on, +.th-filter[aria-expanded='true'] { + color: #0f766e; + background: #f0fdfa; +} + +.col-popover { + inset-inline-start: 0; + inset-inline-end: auto; + width: 16.5rem; + padding: 0.65rem 0.7rem; +} + +.col-popover.align-end { + inset-inline-start: auto; + inset-inline-end: 0; +} + .trow { position: relative; border-bottom: 1px solid #f8fafc;