feat(activities): step through the same desk list from a detail
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user