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:
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user