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
@@ -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>