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