feat(activities): filter the desk from each column

Assignees are searched instead of listed in full, and start time only filters whether a time is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 21:20:42 +03:30
co-authored by Cursor
parent d5dd13863a
commit 4af248b06a
6 changed files with 769 additions and 83 deletions
+187 -61
View File
@@ -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<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]);
const filterOpen = ref(false);
const openCol = ref<ActColKey | null>(null);
const ideasOpen = ref(false);
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
@@ -240,6 +242,16 @@ const statusFilter = computed<StatusFilter>(() => {
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<Record<string, string | undefined>>(() => ({
: 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<string, string> = {};
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"
>
<UiIcon name="filter" :size="15" />
<span class="filter-label">{{ filterBtnLabel }}</span>
<UiIcon name="chevron" :size="13" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
<p class="facet-title">نوع کار</p>
<button
v-for="opt in TYPE_FILTERS"
: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>
<ActivityDeskFilters
field="all"
:title="titleFilter"
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@clear="clearFacets"
/>
</div>
</div>
</div>
@@ -1522,7 +1557,46 @@ onUnmounted(() => {
<span class="row-num">#</span>
<span v-if="selectMode" class="tick-col" />
<template v-for="col in ACT_COLUMNS" :key="col.key">
<span v-if="colOn(col.key)">{{ col.label }}</span>
<div v-if="colOn(col.key)" class="th-cell" @click.stop>
<span>{{ col.label }}</span>
<button
v-if="col.key !== 'who' || showDeskTabs"
type="button"
class="th-filter"
:class="{ on: colFiltered(col.key) }"
:aria-label="`فیلتر ${col.label}`"
:aria-expanded="openCol === col.key"
@click="toggleCol(col.key)"
>
<UiIcon name="filter" :size="12" />
</button>
<div
v-if="openCol === col.key"
class="popover col-popover"
:class="{ 'align-end': col.key === 'when' || col.key === 'status' }"
>
<ActivityDeskFilters
:field="col.key"
:title="titleFilter"
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@done="onFacetDone"
/>
</div>
</div>
</template>
<span />
</div>
@@ -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;