Add activity priority desk filter and keep detail browsing inside the same column after stage changes.

When done/reopen/stage moves leave a column, advance to the next activity there or return to the list if empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-14 12:02:13 +03:30
co-authored by Cursor
parent ae16e43013
commit 33eeb3242a
7 changed files with 209 additions and 32 deletions
@@ -210,6 +210,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
);
@@ -246,6 +247,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -279,6 +281,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -123,6 +123,7 @@ type ListFacets = {
customer?: string;
/** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset';
priority?: ActivityDto['priority'];
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
};
@@ -1369,11 +1370,13 @@ export class ActivitiesService {
const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim();
const facetPriority = facets?.priority;
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetPriority ? [{ priority: facetPriority }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee
+5
View File
@@ -93,6 +93,11 @@ export class ListActivitiesQueryDto {
@IsString()
typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
@@ -511,6 +511,7 @@ function activityQuery(id: string) {
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.priority) query.priority = source.priority;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
export type DeskFilterField =
| 'title'
| 'who'
| 'customer'
| 'type'
| 'priority'
| 'when'
| 'status';
const props = defineProps<{
/** یک ستون، یا پنل کامل بالای صفحه */
@@ -10,11 +17,13 @@ const props = defineProps<{
customer: string;
due: '' | 'set' | 'unset';
typeKey: string;
priorityKey: string;
statusKey: string;
assigneeId: string;
showAssignee: boolean;
users: Array<{ id: string; fullName: string }>;
types: Array<{ key: string; label: string }>;
priorities: Array<{ key: string; label: string; color?: string }>;
statuses: Array<{ key: string; label: string }>;
}>();
@@ -23,6 +32,7 @@ const emit = defineEmits<{
customer: [value: string];
due: [value: '' | 'set' | 'unset'];
type: [value: string];
priority: [value: string];
status: [value: string];
assignee: [value: string];
clear: [];
@@ -86,6 +96,7 @@ const hasAny = computed(
() =>
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
props.typeKey !== 'all' ||
props.priorityKey !== 'all' ||
props.statusKey !== 'all',
);
@@ -101,6 +112,9 @@ const whoLabel = computed(
const typeLabel = computed(
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
);
const priorityLabel = computed(
() => props.priorities.find((item) => item.key === props.priorityKey)?.label || 'همه',
);
const statusLabel = computed(
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
);
@@ -144,6 +158,11 @@ function pickType(next: string) {
finish();
}
function pickPriority(next: string) {
emit('priority', props.priorityKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickStatus(next: string) {
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
finish();
@@ -282,6 +301,36 @@ function pickAssignee(next: string) {
</div>
</section>
<section v-if="shows('priority')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: priorityKey !== 'all' }"
@click="toggleSection('priority')"
>
<span>اولویت</span>
<em>{{ priorityLabel }}</em>
</button>
<div v-if="opened('priority')" class="pick" role="listbox" aria-label="اولویت">
<button
v-for="opt in priorities"
:key="opt.key"
type="button"
:class="{ on: priorityKey === opt.key }"
@click="pickPriority(opt.key)"
>
<span
v-if="opt.color && opt.key !== 'all'"
class="prio-dot"
:style="{ background: opt.color }"
aria-hidden="true"
/>
{{ opt.label }}
</button>
</div>
</section>
<section v-if="shows('when')">
<button
v-if="field === 'all'"
@@ -434,6 +483,9 @@ function pickAssignee(next: string) {
}
.pick button {
display: flex;
align-items: center;
gap: 0.4rem;
text-align: start;
border-radius: 8px;
padding: 0.42rem 0.5rem;
@@ -450,6 +502,13 @@ function pickAssignee(next: string) {
font-weight: 700;
}
.prio-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex: none;
}
.seg {
display: flex;
gap: 0.25rem;
+41 -2
View File
@@ -43,6 +43,7 @@ import type {
ActivityBoardDto,
ActivityDto,
ActivityFilter,
ActivityPriority,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
@@ -51,7 +52,7 @@ import type {
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { ACTIVITY_PRIORITY_KEYS, brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
@@ -95,6 +96,7 @@ const actGridStyle = computed(() => ({
/** 'all' یا شناسهٔ نوع فعالیت */
type TypeFilter = string;
type PriorityFilter = 'all' | ActivityPriority;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
@@ -122,6 +124,18 @@ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'canceled', label: 'لغو شده' },
];
const activityPriorities = useActivityPriorities();
const PRIORITY_FILTERS = computed<
Array<{ key: PriorityFilter; label: string; color?: string }>
>(() => [
{ key: 'all', label: 'همه اولویت‌ها' },
...activityPriorities.items.value.map((row) => ({
key: row.key as PriorityFilter,
label: row.label,
color: row.color,
})),
]);
const pageSize = usePageSize('activities', 10);
const route = useRoute();
@@ -181,7 +195,7 @@ const boardDeleteOpen = ref(false);
async function onTypesChanged() {
await activityTypes.load(true);
await useActivityPriorities().load(true);
await activityPriorities.load(true);
kanbanReload.value += 1;
await refreshAll();
}
@@ -245,6 +259,13 @@ const statusFilter = computed<StatusFilter>(() => {
: 'all';
});
const priorityFilter = computed<PriorityFilter>(() => {
const value = typeof route.query.priority === 'string' ? route.query.priority : 'all';
return ACTIVITY_PRIORITY_KEYS.includes(value as ActivityPriority)
? (value as ActivityPriority)
: 'all';
});
const assigneeFilter = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
@@ -323,6 +344,7 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
? legacyBaseType.value
: undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
priority: priorityFilter.value === 'all' ? undefined : priorityFilter.value,
assigneeId: assigneeFilter.value || undefined,
title: titleFilter.value.trim() || undefined,
customer: customerFilter.value.trim() || undefined,
@@ -335,6 +357,7 @@ function stageDotStyle(s: ActivityStageDto) {
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
if (priorityFilter.value !== 'all') n += 1;
if (statusFilter.value !== 'all') n += 1;
if (assigneeFilter.value) n += 1;
if (titleFilter.value.trim()) n += 1;
@@ -437,6 +460,7 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
if (search) query.q = search;
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
if (statusFilter.value !== 'all') query.status = statusFilter.value;
if (priorityFilter.value !== 'all') query.priority = priorityFilter.value;
if (assigneeFilter.value) query.assigneeId = assigneeFilter.value;
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
@@ -775,6 +799,13 @@ function setStatusFilter(next: string) {
patchFacetQuery({ status: value === 'all' ? undefined : value });
}
function setPriorityFilter(next: string) {
const value: PriorityFilter = ACTIVITY_PRIORITY_KEYS.includes(next as ActivityPriority)
? (next as ActivityPriority)
: 'all';
patchFacetQuery({ priority: value === 'all' ? undefined : value });
}
function setAssigneeFilter(next: string) {
patchFacetQuery({
assigneeId: next || undefined,
@@ -799,6 +830,7 @@ function clearFacets() {
type: undefined,
typeDefId: undefined,
status: undefined,
priority: undefined,
assigneeId: undefined,
title: undefined,
customer: undefined,
@@ -1164,6 +1196,7 @@ onMounted(() => {
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void activityTypes.load(true);
void activityPriorities.load();
void loadBoards()
.then(() => loadStages())
.then(() => {
@@ -1461,16 +1494,19 @@ onUnmounted(() => {
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@clear="clearFacets"
@@ -1680,16 +1716,19 @@ onUnmounted(() => {
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@done="onFacetDone"
+96 -29
View File
@@ -52,8 +52,33 @@ import {
const route = useRoute();
const router = useRouter();
const ACTIVITY_LIST_QUERY = ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
const NAV_API_QUERY = ['q', 'desk', 'type', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
const ACTIVITY_LIST_QUERY = [
'board',
'view',
'desk',
'filter',
'stage',
'q',
'typeDefId',
'status',
'priority',
'assigneeId',
'title',
'customer',
'due',
] as const;
const NAV_API_QUERY = [
'q',
'desk',
'type',
'typeDefId',
'status',
'priority',
'assigneeId',
'title',
'customer',
'due',
] as const;
function activitiesListLocation() {
const query: Record<string, string> = {};
@@ -800,6 +825,7 @@ function moveToStage(stageId: string) {
if (activity.value.stageId === stageId) return;
const target = boardStages.value.find((stage) => stage.id === stageId);
if (!target) return;
const leftId = id.value;
Modal.confirm({
title: 'جابه‌جایی مرحله',
content: `به ستون «${target.name}» منتقل می‌شود. انجام شود؟`,
@@ -810,7 +836,7 @@ function moveToStage(stageId: string) {
stageMoving.value = true;
error.value = '';
try {
const { data } = await api.patch<ActivityDto>(`/activities/${id.value}/stage`, { stageId });
const { data } = await api.patch<ActivityDto>(`/activities/${leftId}/stage`, { stageId });
if (data.status !== target.kind) {
message.info(
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
@@ -818,8 +844,8 @@ function moveToStage(stageId: string) {
await load();
return;
}
activity.value = data;
hydrateDraft(data);
discardDrafts();
await advanceAfterLeavingColumn(leftId);
} catch {
error.value = 'جابه‌جایی مرحله ناموفق بود.';
throw new Error('stage-move-failed');
@@ -877,20 +903,20 @@ async function loadUsers() {
async function complete(kind: 'share' | 'force' | 'undo' = 'share') {
if (!id.value) return;
if (kind !== 'undo' && isDone.value) return;
const leftId = id.value;
saving.value = true;
error.value = '';
try {
const params =
kind === 'force' ? { force: '1' } : kind === 'undo' ? { undo: '1' } : undefined;
const { data } = await api.patch<ActivityDto>(
`/activities/${id.value}/complete`,
`/activities/${leftId}/complete`,
undefined,
{ params },
);
if (data.status === 'done') {
// مثل بازگشت و پیش‌نمایش: فیلتر/نمای قبلی حفظ شود
discardDrafts();
leaveToActivitiesList();
await advanceAfterLeavingColumn(leftId);
return;
}
activity.value = data;
@@ -928,11 +954,13 @@ async function reopen() {
warnNoMutatePermission();
return;
}
const leftId = id.value;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id.value}`, { status: 'open' });
await load();
await api.patch(`/activities/${leftId}`, { status: 'open' });
discardDrafts();
await advanceAfterLeavingColumn(leftId);
} catch {
error.value = 'بازگشایی کار ناموفق بود.';
} finally {
@@ -1003,6 +1031,13 @@ const navIds = ref<string[]>([]);
const deskBrowse = computed(
() => route.query.from === 'desk' && typeof route.query.nav === 'string' && route.query.nav.length > 0,
);
const navColumnStageId = computed(() => {
const fromQuery =
typeof route.query.stage === 'string' && route.query.stage && route.query.stage !== 'all'
? route.query.stage
: '';
return fromQuery || activity.value?.stageId || '';
});
const navIndex = computed(() => navIds.value.indexOf(id.value));
const prevNavId = computed(() => (navIndex.value > 0 ? navIds.value[navIndex.value - 1]! : ''));
const nextNavId = computed(() =>
@@ -1012,13 +1047,13 @@ const nextNavId = computed(() =>
);
const showNav = computed(() => deskBrowse.value && navIndex.value >= 0);
function navStorageKey(token: string) {
return `activity-nav:${token}`;
function navStorageKey(token: string, stageId: string) {
return `activity-nav:${token}:${stageId || 'none'}`;
}
function readNavCache(token: string): string[] | null {
function readNavCache(token: string, stageId: string): string[] | null {
try {
const raw = sessionStorage.getItem(navStorageKey(token));
const raw = sessionStorage.getItem(navStorageKey(token, stageId));
if (!raw) return null;
const parsed = JSON.parse(raw) as { ids?: unknown };
if (!Array.isArray(parsed.ids) || parsed.ids.some((item) => typeof item !== 'string')) return null;
@@ -1028,42 +1063,70 @@ function readNavCache(token: string): string[] | null {
}
}
function writeNavCache(token: string, stageId: string, ids: string[]) {
try {
sessionStorage.setItem(navStorageKey(token, stageId), JSON.stringify({ ids }));
} catch {
/* حالت خصوصی مرورگر */
}
}
/** بعد از ترک ستون: برو بعدیِ همان ستون، وگرنه قبلی؛ اگر خالی بود برو فهرست. */
async function advanceAfterLeavingColumn(leftId: string) {
commentOpen.value = false;
postponeOpen.value = false;
if (!deskBrowse.value) {
leaveToActivitiesList();
return;
}
const list = navIds.value;
const idx = list.indexOf(leftId);
const target =
idx >= 0 && idx < list.length - 1
? list[idx + 1]!
: idx > 0
? list[idx - 1]!
: '';
const remaining = list.filter((item) => item !== leftId);
navIds.value = remaining;
const token = typeof route.query.nav === 'string' ? route.query.nav : '';
const stageId = navColumnStageId.value;
if (token) writeNavCache(token, stageId, remaining);
if (target) {
await router.push({ name: 'activity-detail', params: { id: target }, query: route.query });
return;
}
leaveToActivitiesList();
}
let navSeq = 0;
async function loadNav() {
const seq = ++navSeq;
const token = typeof route.query.nav === 'string' ? route.query.nav : '';
const columnMissing =
route.query.view === 'board' &&
(typeof route.query.stage !== 'string' || !route.query.stage || route.query.stage === 'all');
if (!deskBrowse.value || !token || columnMissing) {
navIds.value = [];
const stageId = navColumnStageId.value;
if (!deskBrowse.value || !token || !stageId) {
if (seq === navSeq) navIds.value = [];
return;
}
const cached = readNavCache(token);
const cached = readNavCache(token, stageId);
if (cached) {
if (seq === navSeq) navIds.value = cached;
return;
}
const params: Record<string, string> = {
filter: typeof route.query.filter === 'string' && route.query.filter ? route.query.filter : 'today',
stageId,
};
for (const key of NAV_API_QUERY) {
const value = route.query[key];
if (typeof value === 'string' && value) params[key] = value;
}
if (typeof route.query.board === 'string' && route.query.board) params.boardId = route.query.board;
if (typeof route.query.stage === 'string' && route.query.stage && route.query.stage !== 'all') {
params.stageId = route.query.stage;
}
try {
const { data } = await api.get<{ ids: string[] }>('/activities/nav-ids', { params });
if (seq !== navSeq) return;
const ids = Array.isArray(data.ids) ? data.ids : [];
try {
sessionStorage.setItem(navStorageKey(token), JSON.stringify({ ids }));
} catch {
/* حالت خصوصی مرورگر */
}
writeNavCache(token, stageId, ids);
navIds.value = ids;
} catch {
if (seq === navSeq) navIds.value = [];
@@ -1083,7 +1146,11 @@ watch(id, () => {
postponeOpen.value = false;
void load();
});
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
watch(
() => [route.query.nav, navColumnStageId.value] as const,
() => void loadNav(),
{ immediate: true },
);
/** بدون next() — در Vue Router 4 گارد async+next باعث رد شدن بدون پرسش می‌شد */
onBeforeRouteLeave(async () => resolveDirty());
onMounted(() => {