Add saved report views with personal/org filters and in-org share links.

This commit is contained in:
2026-09-27 11:11:33 +03:30
parent 6a9e864b07
commit a16c2f0a9b
7 changed files with 550 additions and 34 deletions
+1 -1
View File
@@ -1630,7 +1630,7 @@ model SavedView {
ownerUserId String?
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
name String
/// deals | people | companies | activities | project_cards
/// deals | people | companies | activities | project_cards | reports
scope String
filters Json @default("{}")
isShared Boolean @default(false)
+106 -2
View File
@@ -1,14 +1,63 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsBoolean,
IsObject,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { SavedViewsService } from '../saved-views/saved-views.service';
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
import { ReportsService } from './reports.service';
class CreateReportViewDto {
@IsString()
@MaxLength(120)
name!: string;
@IsObject()
filters!: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
class UpdateReportViewDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsObject()
filters?: Record<string, unknown>;
@IsOptional()
@IsBoolean()
isShared?: boolean;
}
@Controller('reports')
@UseGuards(JwtAuthGuard)
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
constructor(
private readonly reportsService: ReportsService,
private readonly savedViews: SavedViewsService,
) {}
@Get('dashboard')
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
@@ -35,4 +84,59 @@ export class ReportsController {
home(@CurrentUser() user: Actor) {
return this.reportsService.home(user);
}
@Get('views')
listViews(@CurrentUser() user: Actor) {
return this.savedViews.list(user.organizationId, user.userId, 'reports');
}
@Get('views/:id')
getView(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.savedViews.get(user.organizationId, user.userId, id, 'reports');
}
@Post('views')
createView(@CurrentUser() user: Actor, @Body() dto: CreateReportViewDto) {
return this.savedViews.create(user.organizationId, user.userId, {
name: dto.name,
scope: 'reports',
filters: dto.filters,
isShared: dto.isShared,
});
}
@Patch('views/:id')
updateView(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateReportViewDto,
) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
return this.savedViews.update(
user.organizationId,
user.userId,
id,
{
name: dto.name,
filters: dto.filters,
isShared: dto.isShared,
scope: 'reports',
},
isAdmin,
'reports',
);
}
@Delete('views/:id')
async removeView(@CurrentUser() user: Actor, @Param('id') id: string) {
const isAdmin = user.role === 'owner' || user.role === 'admin';
await this.savedViews.remove(
user.organizationId,
user.userId,
id,
isAdmin,
'reports',
);
return { ok: true };
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SavedViewsModule } from '../saved-views/saved-views.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, SavedViewsModule],
controllers: [ReportsController],
providers: [ReportsService],
})
@@ -17,6 +17,7 @@ import {
IsString,
MaxLength,
} from 'class-validator';
import { SAVED_VIEW_SCOPES } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -24,21 +25,13 @@ import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { SavedViewsService } from './saved-views.service';
const SCOPES = [
'deals',
'people',
'companies',
'activities',
'project_cards',
] as const;
class CreateSavedViewDto {
@IsString()
@MaxLength(120)
name!: string;
@IsIn(SCOPES)
scope!: (typeof SCOPES)[number];
@IsIn([...SAVED_VIEW_SCOPES])
scope!: (typeof SAVED_VIEW_SCOPES)[number];
@IsObject()
filters!: Record<string, unknown>;
@@ -55,8 +48,8 @@ class UpdateSavedViewDto {
name?: string;
@IsOptional()
@IsIn(SCOPES)
scope?: (typeof SCOPES)[number];
@IsIn([...SAVED_VIEW_SCOPES])
scope?: (typeof SAVED_VIEW_SCOPES)[number];
@IsOptional()
@IsObject()
@@ -81,6 +74,14 @@ export class SavedViewsController {
return this.savedViews.list(user.organizationId, user.userId, scope);
}
@Get(':id')
get(
@CurrentUser() user: { organizationId: string; userId: string },
@Param('id') id: string,
) {
return this.savedViews.get(user.organizationId, user.userId, id);
}
@Post()
create(
@CurrentUser() user: { organizationId: string; userId: string },
@@ -9,17 +9,10 @@ import type {
SavedViewScope,
UpdateSavedViewRequest,
} from '@mento/shared';
import { SAVED_VIEW_SCOPES } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const SCOPES: SavedViewScope[] = [
'deals',
'people',
'companies',
'activities',
'project_cards',
];
@Injectable()
export class SavedViewsService {
constructor(private readonly prisma: PrismaService) {}
@@ -41,6 +34,25 @@ export class SavedViewsService {
return rows.map((row) => this.toDto(row));
}
async get(
organizationId: string,
userId: string,
id: string,
scope?: string,
): Promise<SavedViewDto> {
const row = await this.prisma.savedView.findFirst({
where: {
id,
organizationId,
...(scope ? { scope } : {}),
OR: [{ isShared: true }, { ownerUserId: userId }],
},
include: { owner: { select: { id: true, fullName: true } } },
});
if (!row) throw new NotFoundException('فیلتر پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
@@ -69,11 +81,15 @@ export class SavedViewsService {
id: string,
dto: UpdateSavedViewRequest,
isAdmin: boolean,
expectedScope?: string,
): Promise<SavedViewDto> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (expectedScope && existing.scope !== expectedScope) {
throw new NotFoundException('فیلتر پیدا نشد');
}
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را ویرایش کند');
}
@@ -103,11 +119,15 @@ export class SavedViewsService {
userId: string,
id: string,
isAdmin: boolean,
expectedScope?: string,
): Promise<void> {
const existing = await this.prisma.savedView.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
if (expectedScope && existing.scope !== expectedScope) {
throw new NotFoundException('فیلتر پیدا نشد');
}
if (!isAdmin && existing.ownerUserId !== userId) {
throw new BadRequestException('فقط مالک فیلتر می‌تواند آن را حذف کند');
}
@@ -115,7 +135,7 @@ export class SavedViewsService {
}
private assertScope(scope: string): SavedViewScope {
if (!SCOPES.includes(scope as SavedViewScope)) {
if (!SAVED_VIEW_SCOPES.includes(scope as SavedViewScope)) {
throw new BadRequestException('محدوده فیلتر معتبر نیست');
}
return scope as SavedViewScope;
+380 -8
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatMoney } from '@/lib/money';
import {
dateToJalali,
@@ -16,6 +17,8 @@ import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiChart,
@@ -29,6 +32,8 @@ import type {
ReportsDrilldownDto,
ReportsFunnelStageDto,
ReportsPeriodCompareRowDto,
ReportsViewFilters,
SavedViewDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
@@ -42,7 +47,18 @@ const PERIODS: Array<{ key: PeriodKey; label: string }> = [
{ key: 'year', label: 'سال' },
];
const PERIOD_KEYS = new Set<PeriodKey>([
'today',
'week',
'month',
'quarter',
'year',
'custom',
]);
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const loading = ref(false);
const error = ref('');
const data = ref<ReportsDashboardDto | null>(null);
@@ -57,10 +73,33 @@ const drillLoading = ref(false);
const drillError = ref('');
const drill = ref<ReportsDrilldownDto | null>(null);
const savedViews = ref<SavedViewDto[]>([]);
const activeViewId = ref<string | null>(null);
const viewsLoading = ref(false);
const viewsError = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(true);
const saveBusy = ref(false);
const saveError = ref('');
const shareNote = ref('');
const currency = computed(
() => data.value?.currency || org.value?.baseCurrency || 'IRR',
);
const activeView = computed(
() => savedViews.value.find((v) => v.id === activeViewId.value) ?? null,
);
const canManageActiveView = computed(() => {
const v = activeView.value;
if (!v) return false;
const role = auth.user?.role;
if (role === 'owner' || role === 'admin') return true;
return v.owner?.id === auth.user?.id;
});
const funnelByPipeline = computed(() => {
const rows = data.value?.funnel ?? [];
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
@@ -154,9 +193,14 @@ function openDeal(link: string) {
void router.push(link);
}
function applyPeriod(key: PeriodKey) {
function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) {
period.value = key;
showCustomDates.value = key === 'custom';
if (!opts?.keepView) {
activeViewId.value = null;
shareNote.value = '';
syncViewQuery(null);
}
if (key === 'custom') return;
const now = new Date();
const j = dateToJalali(now);
@@ -186,8 +230,232 @@ function applyPeriod(key: PeriodKey) {
function openCustom() {
period.value = 'custom';
showCustomDates.value = true;
activeViewId.value = null;
syncViewQuery(null);
}
function currentFilters(): ReportsViewFilters {
return {
period: period.value,
from: from.value || undefined,
to: to.value || undefined,
};
}
function parseFilters(raw: Record<string, unknown>): ReportsViewFilters {
const periodRaw = String(raw.period ?? 'month');
const p = PERIOD_KEYS.has(periodRaw as PeriodKey)
? (periodRaw as PeriodKey)
: 'month';
return {
period: p,
from: typeof raw.from === 'string' ? raw.from : undefined,
to: typeof raw.to === 'string' ? raw.to : undefined,
};
}
function syncViewQuery(viewId: string | null) {
const nextQuery = { ...route.query } as Record<string, string | string[]>;
if (viewId) nextQuery.view = viewId;
else delete nextQuery.view;
const cur = typeof route.query.view === 'string' ? route.query.view : null;
if (cur === viewId) return;
void router.replace({ query: nextQuery });
}
async function loadSavedViews() {
viewsLoading.value = true;
viewsError.value = '';
try {
const { data: rows } = await api.get<SavedViewDto[]>('/reports/views');
savedViews.value = rows;
} catch (e) {
savedViews.value = [];
viewsError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری نماهای ذخیره‌شده ناموفق بود.',
)
: 'بارگذاری نماهای ذخیره‌شده ناموفق بود.';
} finally {
viewsLoading.value = false;
}
}
function applyFilters(filters: ReportsViewFilters, keepView: boolean) {
const p = PERIOD_KEYS.has(filters.period as PeriodKey)
? (filters.period as PeriodKey)
: 'month';
if (p === 'custom') {
period.value = 'custom';
showCustomDates.value = true;
from.value = filters.from || formatLocalDateInput(new Date());
to.value = filters.to || formatLocalDateInput(new Date());
if (!keepView) {
activeViewId.value = null;
syncViewQuery(null);
}
void load();
return;
}
applyPeriod(p, { keepView });
}
async function selectSavedView(id: string) {
if (!id) {
activeViewId.value = null;
syncViewQuery(null);
return;
}
try {
const { data: view } = await api.get<SavedViewDto>(`/reports/views/${id}`);
activeViewId.value = view.id;
syncViewQuery(view.id);
applyFilters(parseFilters(view.filters), true);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'باز کردن نما ناموفق بود.',
)
: 'باز کردن نما ناموفق بود.';
}
}
function openSaveModal() {
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? true;
saveError.value = '';
saveOpen.value = true;
}
async function saveCurrentView() {
const name = saveName.value.trim();
if (!name) {
saveError.value = 'نام نما را وارد کنید.';
return;
}
saveBusy.value = true;
saveError.value = '';
try {
const payload = {
name,
filters: currentFilters() as unknown as Record<string, unknown>,
isShared: saveShared.value,
};
let saved: SavedViewDto;
if (activeViewId.value && canManageActiveView.value) {
const { data: row } = await api.patch<SavedViewDto>(
`/reports/views/${activeViewId.value}`,
payload,
);
saved = row;
} else {
const { data: row } = await api.post<SavedViewDto>(
'/reports/views',
payload,
);
saved = row;
}
saveOpen.value = false;
await loadSavedViews();
activeViewId.value = saved.id;
syncViewQuery(saved.id);
shareNote.value = saved.isShared
? 'نما ذخیره و برای سازمان قابل اشتراک شد.'
: 'نما ذخیره شد (فقط برای شما).';
} catch (e) {
saveError.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره نما ناموفق بود.',
)
: 'ذخیره نما ناموفق بود.';
} finally {
saveBusy.value = false;
}
}
async function deleteActiveView() {
if (!activeViewId.value || !canManageActiveView.value) return;
if (!confirm('این نما حذف شود؟')) return;
try {
await api.delete(`/reports/views/${activeViewId.value}`);
activeViewId.value = null;
syncViewQuery(null);
await loadSavedViews();
shareNote.value = 'نما حذف شد.';
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'حذف نما ناموفق بود.',
)
: 'حذف نما ناموفق بود.';
}
}
async function copyShareLink() {
shareNote.value = '';
try {
let view = activeView.value;
if (!view) {
openSaveModal();
saveShared.value = true;
shareNote.value = 'ابتدا نما را ذخیره کنید، بعد لینک را کپی کنید.';
return;
}
if (!view.isShared && canManageActiveView.value) {
const { data: row } = await api.patch<SavedViewDto>(
`/reports/views/${view.id}`,
{ isShared: true },
);
view = row;
await loadSavedViews();
activeViewId.value = row.id;
}
if (!view.isShared) {
shareNote.value = 'این نما اشتراکی نیست؛ فقط مالک می‌تواند اشتراک بگذارد.';
return;
}
const path = router.resolve({
name: 'reports',
query: { view: view.id },
}).href;
const url = `${window.location.origin}${path}`;
await navigator.clipboard.writeText(url);
shareNote.value = 'لینک اشتراک کپی شد.';
} catch {
shareNote.value = 'کپی لینک ناموفق بود.';
}
}
async function bootstrap() {
await loadSavedViews();
const viewId =
typeof route.query.view === 'string' ? route.query.view.trim() : '';
if (viewId) {
await selectSavedView(viewId);
return;
}
applyPeriod('month');
}
watch(
() => route.query.view,
(next, prev) => {
const id = typeof next === 'string' ? next : '';
const prevId = typeof prev === 'string' ? prev : '';
if (id && id !== prevId && id !== activeViewId.value) {
void selectSavedView(id);
}
},
);
onMounted(() => {
void bootstrap();
});
async function load() {
loading.value = true;
error.value = '';
@@ -571,10 +839,6 @@ const activityMax = computed(() =>
1,
),
);
onMounted(() => {
applyPeriod('month');
});
</script>
<template>
@@ -584,6 +848,14 @@ onMounted(() => {
lead="نمای جامع عملکرد فروش، مشتریان، فعالیت‌ها و تیم"
>
<template #actions>
<UiButton variant="secondary" @click="openSaveModal">
<UiIcon name="layers" :size="16" />
ذخیره نما
</UiButton>
<UiButton variant="secondary" @click="copyShareLink">
<UiIcon name="send" :size="16" />
کپی لینک اشتراک
</UiButton>
<UiButton
variant="primary"
:disabled="!data"
@@ -614,10 +886,45 @@ onMounted(() => {
<UiIcon name="calendar" :size="16" />
<span>{{ rangeLabel() }}</span>
</button>
<label class="view-select">
<span>نماهای ذخیره‌شده</span>
<select
:value="activeViewId ?? ''"
:disabled="viewsLoading"
@change="
selectSavedView(($event.target as HTMLSelectElement).value)
"
>
<option value="">پیش‌فرض</option>
<option v-for="v in savedViews" :key="v.id" :value="v.id">
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
</option>
</select>
</label>
<UiButton
v-if="activeViewId && canManageActiveView"
variant="secondary"
@click="deleteActiveView"
>
حذف نما
</UiButton>
</div>
<p v-if="shareNote" class="ok-note">{{ shareNote }}</p>
<p v-if="viewsError" class="error">{{ viewsError }}</p>
<!-- Custom date inputs -->
<form v-if="showCustomDates" class="custom-range" @submit.prevent="load">
<form
v-if="showCustomDates"
class="custom-range"
@submit.prevent="
activeViewId = null;
syncViewQuery(null);
load();
"
>
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
@@ -954,10 +1261,75 @@ onMounted(() => {
<p v-else class="empty-msg">موردی یافت نشد.</p>
</template>
</UiModal>
<UiModal
:open="saveOpen"
:title="activeViewId && canManageActiveView ? 'به‌روزرسانی نما' : 'ذخیره نمای گزارش'"
@close="saveOpen = false"
>
<div class="save-form">
<UiField label="نام نما" for-id="report-view-name">
<UiInput
id="report-view-name"
v-model="saveName"
placeholder="مثلاً فروش ماه جاری تیم"
/>
</UiField>
<label class="share-check">
<input v-model="saveShared" type="checkbox" />
<span>اشتراک با اعضای سازمان (قابل باز شدن با لینک)</span>
</label>
<p v-if="saveError" class="error">{{ saveError }}</p>
</div>
<template #footer>
<UiButton variant="secondary" @click="saveOpen = false">انصراف</UiButton>
<UiButton :loading="saveBusy" @click="saveCurrentView">ذخیره</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.view-select {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h-md);
padding: 0 var(--sp-3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.view-select select {
font: inherit;
border: 0;
background: transparent;
color: var(--ink);
max-width: 220px;
}
.ok-note {
color: #0f766e;
font-weight: 600;
margin: 0 0 var(--sp-3);
}
.save-form {
display: grid;
gap: var(--sp-3);
}
.share-check {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.deep-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+19 -1
View File
@@ -3051,7 +3051,25 @@ export type SavedViewScope =
| "people"
| "companies"
| "activities"
| "project_cards";
| "project_cards"
| "reports";
export const SAVED_VIEW_SCOPES: SavedViewScope[] = [
"deals",
"people",
"companies",
"activities",
"project_cards",
"reports",
];
/** فیلترهای ذخیره‌شدهٔ صفحه گزارشات */
export interface ReportsViewFilters {
/** today | week | month | quarter | year | custom */
period: string;
from?: string;
to?: string;
}
export interface SavedViewDto {
id: string;