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
@@ -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,
+66 -21
View File
@@ -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: {
+17
View File
@@ -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()
@@ -393,7 +393,7 @@ const route = useRoute();
function openActivity(id: string) {
const query: Record<string, string> = {};
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;
}
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
const props = defineProps<{
/** یک ستون، یا پنل کامل بالای صفحه */
field: DeskFilterField | 'all';
title: string;
customer: string;
due: '' | 'set' | 'unset';
typeKey: string;
statusKey: string;
assigneeId: string;
showAssignee: boolean;
users: Array<{ id: string; fullName: string }>;
types: Array<{ key: string; label: string }>;
statuses: Array<{ key: string; label: string }>;
}>();
const emit = defineEmits<{
title: [value: string];
customer: [value: string];
due: [value: '' | 'set' | 'unset'];
type: [value: string];
status: [value: string];
assignee: [value: string];
clear: [];
done: [];
}>();
const LIST_CAP = 40;
const titleDraft = ref(props.title);
const customerDraft = ref(props.customer);
const whoQuery = ref('');
const typeQuery = ref('');
const openSection = ref<DeskFilterField | null>(null);
watch(
() => props.title,
(value) => {
titleDraft.value = value;
},
);
watch(
() => props.customer,
(value) => {
customerDraft.value = value;
},
);
function norm(value: string) {
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
}
const filteredUsers = computed(() => {
const q = norm(whoQuery.value);
const matches = props.users.filter((user) => !q || norm(user.fullName).includes(q));
const selected = props.assigneeId
? props.users.find((user) => user.id === props.assigneeId)
: undefined;
const head = selected && !matches.slice(0, LIST_CAP).some((user) => user.id === selected.id)
? [selected]
: [];
return [...head, ...matches.filter((user) => user.id !== selected?.id)].slice(0, LIST_CAP);
});
const hiddenUsers = computed(() => {
const q = norm(whoQuery.value);
const count = props.users.filter((user) => !q || norm(user.fullName).includes(q)).length;
return Math.max(0, count - filteredUsers.value.length);
});
const showTypeSearch = computed(() => props.types.length > 8);
const visibleTypes = computed(() => {
const q = norm(typeQuery.value);
const all = props.types.filter((item) => item.key === 'all');
const rest = props.types.filter(
(item) => item.key !== 'all' && (!q || norm(item.label).includes(q)),
);
return [...all, ...rest];
});
const hasAny = computed(
() =>
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
props.typeKey !== 'all' ||
props.statusKey !== 'all',
);
const dueOptions: Array<{ key: '' | 'set' | 'unset'; label: string }> = [
{ key: '', label: 'همه' },
{ key: 'set', label: 'دارد' },
{ key: 'unset', label: 'ندارد' },
];
const whoLabel = computed(
() => props.users.find((user) => user.id === props.assigneeId)?.fullName || 'همه',
);
const typeLabel = computed(
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
);
const statusLabel = computed(
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
);
const dueLabel = computed(() => dueOptions.find((item) => item.key === props.due)?.label ?? 'همه');
function opened(name: DeskFilterField) {
return props.field !== 'all' || openSection.value === name;
}
function toggleSection(name: DeskFilterField) {
openSection.value = openSection.value === name ? null : name;
}
function finish() {
if (props.field === 'all') openSection.value = null;
else emit('done');
}
function shows(name: DeskFilterField) {
if (name === 'who' && !props.showAssignee) return false;
return props.field === 'all' || props.field === name;
}
function applyTitle() {
emit('title', titleDraft.value.trim());
finish();
}
function applyCustomer() {
emit('customer', customerDraft.value.trim());
finish();
}
function pickDue(next: '' | 'set' | 'unset') {
emit('due', props.due === next ? '' : next);
finish();
}
function pickType(next: string) {
emit('type', props.typeKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickStatus(next: string) {
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickAssignee(next: string) {
emit('assignee', props.assigneeId === next ? '' : next);
finish();
}
</script>
<template>
<div class="filters" @click.stop>
<section v-if="shows('title')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(title.trim()) }"
@click="toggleSection('title')"
>
<span>عنوان فعالیت</span>
<em>{{ title.trim() || 'همه' }}</em>
</button>
<form v-if="opened('title')" class="draft" @submit.prevent="applyTitle">
<input
v-model="titleDraft"
type="search"
placeholder="بخشی از عنوان"
autocomplete="off"
/>
<button type="submit">اعمال</button>
<button v-if="title" type="button" class="quiet" @click="titleDraft = ''; applyTitle()">
پاک
</button>
</form>
</section>
<section v-if="shows('who')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(assigneeId) }"
@click="toggleSection('who')"
>
<span>کارشناس</span>
<em>{{ whoLabel }}</em>
</button>
<input
v-if="opened('who')"
v-model="whoQuery"
class="find"
type="search"
placeholder="جستجوی نام کارشناس"
autocomplete="off"
/>
<div v-if="opened('who')" class="pick" role="listbox" aria-label="کارشناس">
<button type="button" :class="{ on: !assigneeId }" @click="pickAssignee('')">
همه کارشناسان
</button>
<button
v-for="user in filteredUsers"
:key="user.id"
type="button"
:class="{ on: assigneeId === user.id }"
@click="pickAssignee(user.id)"
>
{{ user.fullName }}
</button>
<p v-if="whoQuery.trim() && !filteredUsers.length" class="empty">کارشناسی با این نام نیست</p>
</div>
<p v-if="opened('who') && hiddenUsers" class="hint">برای دیدن بقیه، نام را دقیق‌تر بنویسید</p>
</section>
<section v-if="shows('customer')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(customer.trim()) }"
@click="toggleSection('customer')"
>
<span>مشتری</span>
<em>{{ customer.trim() || 'همه' }}</em>
</button>
<form v-if="opened('customer')" class="draft" @submit.prevent="applyCustomer">
<input
v-model="customerDraft"
type="search"
placeholder="نام شخص یا شرکت"
autocomplete="off"
/>
<button type="submit">اعمال</button>
<button
v-if="customer"
type="button"
class="quiet"
@click="customerDraft = ''; applyCustomer()"
>
پاک
</button>
</form>
</section>
<section v-if="shows('type')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: typeKey !== 'all' }"
@click="toggleSection('type')"
>
<span>نوع فعالیت</span>
<em>{{ typeLabel }}</em>
</button>
<input
v-if="opened('type') && showTypeSearch"
v-model="typeQuery"
class="find"
type="search"
placeholder="جستجوی نوع"
autocomplete="off"
/>
<div v-if="opened('type')" class="pick" role="listbox" aria-label="نوع فعالیت">
<button
v-for="opt in visibleTypes"
:key="opt.key"
type="button"
:class="{ on: typeKey === opt.key }"
@click="pickType(opt.key)"
>
{{ opt.label }}
</button>
<p v-if="visibleTypes.length <= 1 && typeQuery.trim()" class="empty">این نوع پیدا نشد</p>
</div>
</section>
<section v-if="shows('when')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(due) }"
@click="toggleSection('when')"
>
<span>زمان شروع</span>
<em>{{ dueLabel }}</em>
</button>
<p v-else class="hint tight">فقط بودن یا نبودن زمان شروع</p>
<div v-if="opened('when')" class="seg" role="radiogroup" aria-label="زمان شروع">
<button
v-for="opt in dueOptions"
:key="opt.key || 'any'"
type="button"
role="radio"
:aria-checked="due === opt.key"
:class="{ on: due === opt.key }"
@click="pickDue(opt.key)"
>
{{ opt.label }}
</button>
</div>
</section>
<section v-if="shows('status')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: statusKey !== 'all' }"
@click="toggleSection('status')"
>
<span>وضعیت</span>
<em>{{ statusLabel }}</em>
</button>
<div v-if="opened('status')" class="pick" role="listbox" aria-label="وضعیت">
<button
v-for="opt in statuses"
:key="opt.key"
type="button"
:class="{ on: statusKey === opt.key }"
@click="pickStatus(opt.key)"
>
{{ opt.label }}
</button>
</div>
</section>
<button
v-if="field === 'all' && hasAny"
type="button"
class="clear"
@click="openSection = null; emit('clear')"
>
پاک کردن فیلترها
</button>
</div>
</template>
<style scoped>
.filters {
display: grid;
gap: 0.65rem;
}
.acc {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
margin: 0;
padding: 0.28rem 0.15rem;
border: 0;
background: transparent;
font: inherit;
text-align: start;
cursor: pointer;
color: #0f172a;
}
.acc em {
font-style: normal;
color: #94a3b8;
font-size: 0.78rem;
max-width: 9.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acc.on em {
color: #0f766e;
font-weight: 700;
}
.draft {
display: flex;
gap: 0.3rem;
}
.draft input,
.find {
width: 100%;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 0.4rem 0.55rem;
font: inherit;
color: #0f172a;
}
.draft input {
flex: 1;
}
.draft button,
.pick button,
.seg button,
.clear {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.draft button {
flex: none;
border-radius: 8px;
background: #0f766e;
color: #fff;
padding: 0.4rem 0.65rem;
}
.draft button.quiet {
background: #f1f5f9;
color: #475569;
}
.pick {
max-height: 11.5rem;
overflow: auto;
display: grid;
align-content: start;
margin-top: 0.35rem;
}
.pick button {
text-align: start;
border-radius: 8px;
padding: 0.42rem 0.5rem;
color: #0f172a;
}
.pick button:hover,
.pick button.on {
background: #f0fdfa;
color: #0f766e;
}
.pick button.on {
font-weight: 700;
}
.seg {
display: flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: #f8fafc;
}
.seg button {
flex: 1;
border-radius: 8px;
padding: 0.4rem 0.2rem;
color: #475569;
}
.seg button.on {
background: #fff;
color: #0f766e;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.empty,
.hint {
margin: 0.25rem 0.2rem 0;
color: #94a3b8;
font-size: 0.75rem;
}
.hint.tight {
margin: 0 0 0.35rem;
}
.clear {
border-top: 1px solid #e2e8f0;
padding: 0.55rem 0.2rem 0.15rem;
text-align: start;
color: #b91c1c;
}
</style>
+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;