From e6e90aabf3baa813660f7f638babe85b40651650 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 2 Nov 2026 14:30:17 +0330 Subject: [PATCH] feat(activities): step through the same desk list from a detail Co-authored-by: Cursor --- .../src/activities/activities.controller.ts | 33 +++++ apps/api/src/activities/activities.service.ts | 118 ++++++++++++++--- .../activities/ActivitiesKanban.vue | 32 +++-- apps/web/src/views/ActivitiesView.vue | 48 ++++++- apps/web/src/views/ActivityDetailView.vue | 123 +++++++++++++++++- 5 files changed, 318 insertions(+), 36 deletions(-) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 610ce96..d3f8930 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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, diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index c8d2796..cf62a79 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -265,6 +265,102 @@ export class ActivitiesService { facets?: ListFacets, stageScope?: StageScope, ): Promise> { + 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) }, }; } diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index 184aa6c..d87a2bc 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -1,6 +1,6 @@ diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 653643c..d44c8fc 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -260,6 +260,7 @@ const activityModal = ref<{ const menuId = ref(null); const boardMenuId = ref(null); const boardMenuActivity = ref(null); +const boardMenuStageId = ref(''); const boardMenuStyle = ref | null>(null); const deleteActivityIds = ref([]); const deleteSingleTitle = ref(''); @@ -416,14 +417,49 @@ async function loadStages() { } } -function activityRoute(id: string, edit = false) { +/** زمینهٔ همان فهرست میز، برای فعالیت قبلی/بعدی. هر بار باز کردن یک nav تازه می‌سازد. */ +function deskDetailQuery(columnStageId?: string): Record { + const query: Record = { + 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 = {}; 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(() => { مشاهده diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 59ee067..7bf1fc7 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -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 = {}; @@ -296,11 +298,91 @@ async function remove() { } } +const navIds = ref([]); +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 = { + 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(() => {

جزئیات فعالیت

+

در حال بارگذاری…

{{ error }}

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