feat(activities): step through the same desk list from a detail

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 14:30:17 +03:30
co-authored by Cursor
parent a0936bf19f
commit e6e90aabf3
5 changed files with 318 additions and 36 deletions
@@ -238,6 +238,39 @@ export class ActivitiesController {
);
}
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
@Get('nav-ids')
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
return this.activitiesService.orderedIds(
user.organizationId,
user,
query.filter ?? 'today',
query.q,
{
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
leadId: query.leadId,
},
{
from: query.from,
to: query.to,
},
query.desk,
{
type: query.type,
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,
);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
+98 -20
View File
@@ -265,6 +265,102 @@ export class ActivitiesService {
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const { where, ctx } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
};
}
/**
* شناسه‌ها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
* برای ورق‌زدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
*/
async orderedIds(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
): Promise<{ ids: string[]; total: number; truncated: boolean }> {
const cap = 2000;
const { where } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: cap,
}),
this.prisma.activity.count({ where }),
]);
return { ids: rows.map((row) => row.id), total, truncated: total > rows.length };
}
private async listScope(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q: string | undefined,
entity:
| {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
}
| undefined,
range: { from?: string; to?: string } | undefined,
desk: 'mine' | 'team' | undefined,
facets: ListFacets | undefined,
stageScope: StageScope | undefined,
): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
@@ -291,27 +387,9 @@ export class ActivitiesService {
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const ctx: DtoContext = {
stages: boardCtx,
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
where,
ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
};
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import axios from 'axios';
import type {
ActivityDto,
@@ -41,7 +41,7 @@ const emit = defineEmits<{
stagesChanged: [];
add: [stageId: string];
error: [message: string];
menuDeal: [activity: ActivityDto];
menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string];
bulkDelete: [];
}>();
@@ -217,7 +217,7 @@ function findActivity(id: string) {
function onMenuActivity(id: string) {
const item = findActivity(id);
if (item) emit('menuDeal', item);
if (item) emit('menuDeal', item, findStageOf(id));
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
@@ -389,14 +389,26 @@ async function onAddStage(name: string) {
}
}
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', 'title', 'customer', 'due']) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
view: 'board',
};
const source = props.params;
if (source.filter) query.filter = source.filter;
if (source.boardId) query.board = source.boardId;
if (source.desk) query.desk = source.desk;
if (source.q) query.q = source.q;
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
if (source.due) query.due = source.due;
const columnId = findStageOf(id);
if (columnId) query.stage = columnId;
void router.push({ name: 'activity-detail', params: { id }, query });
}
</script>
+43 -5
View File
@@ -260,6 +260,7 @@ const activityModal = ref<{
const menuId = ref<string | null>(null);
const boardMenuId = ref<string | null>(null);
const boardMenuActivity = ref<ActivityDto | null>(null);
const boardMenuStageId = ref('');
const boardMenuStyle = ref<Record<string, string> | null>(null);
const deleteActivityIds = ref<string[]>([]);
const deleteSingleTitle = ref('');
@@ -416,14 +417,49 @@ async function loadStages() {
}
}
function activityRoute(id: string, edit = false) {
/** زمینهٔ همان فهرست میز، برای فعالیت قبلی/بعدی. هر بار باز کردن یک nav تازه می‌سازد. */
function deskDetailQuery(columnStageId?: string): Record<string, string> {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
view: viewMode.value,
filter: filter.value,
};
if (board.value) query.board = board.value;
if (showDeskTabs.value) query.desk = desk.value;
const search = typeof route.query.q === 'string' ? route.query.q.trim() : '';
if (search) query.q = search;
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
if (statusFilter.value !== 'all') query.status = statusFilter.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();
if (dueFilter.value) query.due = dueFilter.value;
const stageId =
viewMode.value === 'board'
? columnStageId
: stage.value !== 'all'
? stage.value
: undefined;
if (stageId) query.stage = stageId;
return query;
}
function activityRoute(id: string, edit = false, columnStageId?: string) {
if (!edit) {
return {
name: 'activity-detail' as const,
params: { id },
query: deskDetailQuery(columnStageId),
};
}
const query: Record<string, string> = {};
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;
}
return {
name: edit ? ('activity-edit' as const) : ('activity-detail' as const),
name: 'activity-edit' as const,
params: { id },
query,
};
@@ -917,9 +953,10 @@ async function confirmDeleteActivities() {
}
}
function onBoardMenu(activity: ActivityDto) {
function onBoardMenu(activity: ActivityDto, stageId?: string) {
boardMenuId.value = boardMenuId.value === activity.id ? null : activity.id;
boardMenuActivity.value = boardMenuId.value ? activity : null;
boardMenuStageId.value = boardMenuId.value ? stageId || activity.stageId || '' : '';
boardMenuStyle.value = null;
if (!boardMenuId.value) return;
void nextTick(() => {
@@ -932,6 +969,7 @@ const unbindBoardMenuDismiss = bindMenuDismiss(
() => {
boardMenuId.value = null;
boardMenuActivity.value = null;
boardMenuStageId.value = '';
},
);
@@ -1857,8 +1895,8 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId)"
@click="boardMenuId = null; boardMenuActivity = null"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
>
مشاهده
</RouterLink>
+122 -1
View File
@@ -7,6 +7,7 @@ import {
formatClockFa,
formatJalaliDay,
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
@@ -28,7 +29,8 @@ import { brand } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const ACTIVITY_LIST_QUERY = ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId'] as const;
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;
function activitiesListLocation() {
const query: Record<string, string> = {};
@@ -296,11 +298,91 @@ async function remove() {
}
}
const navIds = ref<string[]>([]);
const deskBrowse = computed(
() => route.query.from === 'desk' && typeof route.query.nav === 'string' && route.query.nav.length > 0,
);
const navIndex = computed(() => navIds.value.indexOf(id.value));
const prevNavId = computed(() => (navIndex.value > 0 ? navIds.value[navIndex.value - 1]! : ''));
const nextNavId = computed(() =>
navIndex.value >= 0 && navIndex.value < navIds.value.length - 1
? navIds.value[navIndex.value + 1]!
: '',
);
const showNav = computed(() => deskBrowse.value && navIndex.value >= 0);
function navStorageKey(token: string) {
return `activity-nav:${token}`;
}
function readNavCache(token: string): string[] | null {
try {
const raw = sessionStorage.getItem(navStorageKey(token));
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;
return parsed.ids as string[];
} catch {
return null;
}
}
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 = [];
return;
}
const cached = readNavCache(token);
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',
};
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 {
/* حالت خصوصی مرورگر */
}
navIds.value = ids;
} catch {
if (seq === navSeq) navIds.value = [];
}
}
function goNeighbor(targetId: string) {
if (!targetId || targetId === id.value) return;
commentOpen.value = false;
postponeOpen.value = false;
moreOpen.value = false;
void router.push({ name: 'activity-detail', params: { id: targetId }, query: route.query });
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
void load();
});
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
onMounted(() => {
void load();
void loadUsers();
@@ -327,6 +409,15 @@ onMounted(() => {
</header>
<h1>جزئیات فعالیت</h1>
<nav v-if="showNav" class="neighbors" aria-label="فعالیت‌های همین فهرست">
<button type="button" class="neighbor" :disabled="!prevNavId" @click="goNeighbor(prevNavId)">
قبلی
</button>
<span>{{ toFaDigits(navIndex + 1) }} از {{ toFaDigits(navIds.length) }}</span>
<button type="button" class="neighbor" :disabled="!nextNavId" @click="goNeighbor(nextNavId)">
بعدی
</button>
</nav>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error && !activity" class="error">{{ error }}</p>
@@ -609,6 +700,36 @@ h1 {
font-size: 1.35rem;
}
.neighbors {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.neighbors span {
color: #64748b;
font-size: 0.82rem;
font-weight: 600;
}
.neighbor {
border: 0;
background: #fff;
color: #0f766e;
font: inherit;
font-weight: 700;
border-radius: var(--radius);
padding: 0.35rem 0.85rem;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.neighbor:disabled {
color: #94a3b8;
cursor: default;
}
.muted,
.error {
margin: 0;