Add saved report views with personal/org filters and in-org share links.
This commit is contained in:
@@ -1630,7 +1630,7 @@ model SavedView {
|
|||||||
ownerUserId String?
|
ownerUserId String?
|
||||||
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||||
name String
|
name String
|
||||||
/// deals | people | companies | activities | project_cards
|
/// deals | people | companies | activities | project_cards | reports
|
||||||
scope String
|
scope String
|
||||||
filters Json @default("{}")
|
filters Json @default("{}")
|
||||||
isShared Boolean @default(false)
|
isShared Boolean @default(false)
|
||||||
|
|||||||
@@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { SavedViewsService } from '../saved-views/saved-views.service';
|
||||||
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
|
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
|
||||||
import { ReportsService } from './reports.service';
|
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')
|
@Controller('reports')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private readonly reportsService: ReportsService) {}
|
constructor(
|
||||||
|
private readonly reportsService: ReportsService,
|
||||||
|
private readonly savedViews: SavedViewsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
|
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
|
||||||
@@ -35,4 +84,59 @@ export class ReportsController {
|
|||||||
home(@CurrentUser() user: Actor) {
|
home(@CurrentUser() user: Actor) {
|
||||||
return this.reportsService.home(user);
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SavedViewsModule } from '../saved-views/saved-views.module';
|
||||||
import { ReportsController } from './reports.controller';
|
import { ReportsController } from './reports.controller';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, SavedViewsModule],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { SAVED_VIEW_SCOPES } from '@mento/shared';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
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 { RequireOrgModule } from '../billing/org-module.decorator';
|
||||||
import { SavedViewsService } from './saved-views.service';
|
import { SavedViewsService } from './saved-views.service';
|
||||||
|
|
||||||
const SCOPES = [
|
|
||||||
'deals',
|
|
||||||
'people',
|
|
||||||
'companies',
|
|
||||||
'activities',
|
|
||||||
'project_cards',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
class CreateSavedViewDto {
|
class CreateSavedViewDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsIn(SCOPES)
|
@IsIn([...SAVED_VIEW_SCOPES])
|
||||||
scope!: (typeof SCOPES)[number];
|
scope!: (typeof SAVED_VIEW_SCOPES)[number];
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
filters!: Record<string, unknown>;
|
filters!: Record<string, unknown>;
|
||||||
@@ -55,8 +48,8 @@ class UpdateSavedViewDto {
|
|||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(SCOPES)
|
@IsIn([...SAVED_VIEW_SCOPES])
|
||||||
scope?: (typeof SCOPES)[number];
|
scope?: (typeof SAVED_VIEW_SCOPES)[number];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@@ -81,6 +74,14 @@ export class SavedViewsController {
|
|||||||
return this.savedViews.list(user.organizationId, user.userId, scope);
|
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()
|
@Post()
|
||||||
create(
|
create(
|
||||||
@CurrentUser() user: { organizationId: string; userId: string },
|
@CurrentUser() user: { organizationId: string; userId: string },
|
||||||
|
|||||||
@@ -9,17 +9,10 @@ import type {
|
|||||||
SavedViewScope,
|
SavedViewScope,
|
||||||
UpdateSavedViewRequest,
|
UpdateSavedViewRequest,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
import { SAVED_VIEW_SCOPES } from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
const SCOPES: SavedViewScope[] = [
|
|
||||||
'deals',
|
|
||||||
'people',
|
|
||||||
'companies',
|
|
||||||
'activities',
|
|
||||||
'project_cards',
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SavedViewsService {
|
export class SavedViewsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -41,6 +34,25 @@ export class SavedViewsService {
|
|||||||
return rows.map((row) => this.toDto(row));
|
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(
|
async create(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -69,11 +81,15 @@ export class SavedViewsService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateSavedViewRequest,
|
dto: UpdateSavedViewRequest,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
|
expectedScope?: string,
|
||||||
): Promise<SavedViewDto> {
|
): Promise<SavedViewDto> {
|
||||||
const existing = await this.prisma.savedView.findFirst({
|
const existing = await this.prisma.savedView.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||||
|
if (expectedScope && existing.scope !== expectedScope) {
|
||||||
|
throw new NotFoundException('فیلتر پیدا نشد');
|
||||||
|
}
|
||||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را ویرایش کند');
|
throw new BadRequestException('فقط مالک فیلتر میتواند آن را ویرایش کند');
|
||||||
}
|
}
|
||||||
@@ -103,11 +119,15 @@ export class SavedViewsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
id: string,
|
id: string,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
|
expectedScope?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await this.prisma.savedView.findFirst({
|
const existing = await this.prisma.savedView.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
});
|
});
|
||||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||||
|
if (expectedScope && existing.scope !== expectedScope) {
|
||||||
|
throw new NotFoundException('فیلتر پیدا نشد');
|
||||||
|
}
|
||||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را حذف کند');
|
throw new BadRequestException('فقط مالک فیلتر میتواند آن را حذف کند');
|
||||||
}
|
}
|
||||||
@@ -115,7 +135,7 @@ export class SavedViewsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private assertScope(scope: string): SavedViewScope {
|
private assertScope(scope: string): SavedViewScope {
|
||||||
if (!SCOPES.includes(scope as SavedViewScope)) {
|
if (!SAVED_VIEW_SCOPES.includes(scope as SavedViewScope)) {
|
||||||
throw new BadRequestException('محدوده فیلتر معتبر نیست');
|
throw new BadRequestException('محدوده فیلتر معتبر نیست');
|
||||||
}
|
}
|
||||||
return scope as SavedViewScope;
|
return scope as SavedViewScope;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { formatMoney } from '@/lib/money';
|
import { formatMoney } from '@/lib/money';
|
||||||
import {
|
import {
|
||||||
dateToJalali,
|
dateToJalali,
|
||||||
@@ -16,6 +17,8 @@ import UiIcon from '@/components/UiIcon.vue';
|
|||||||
import {
|
import {
|
||||||
UiButton,
|
UiButton,
|
||||||
UiCard,
|
UiCard,
|
||||||
|
UiField,
|
||||||
|
UiInput,
|
||||||
UiModal,
|
UiModal,
|
||||||
UiPageHeader,
|
UiPageHeader,
|
||||||
UiChart,
|
UiChart,
|
||||||
@@ -29,6 +32,8 @@ import type {
|
|||||||
ReportsDrilldownDto,
|
ReportsDrilldownDto,
|
||||||
ReportsFunnelStageDto,
|
ReportsFunnelStageDto,
|
||||||
ReportsPeriodCompareRowDto,
|
ReportsPeriodCompareRowDto,
|
||||||
|
ReportsViewFilters,
|
||||||
|
SavedViewDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
|
||||||
@@ -42,7 +47,18 @@ const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
|||||||
{ key: 'year', label: 'سال' },
|
{ key: 'year', label: 'سال' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PERIOD_KEYS = new Set<PeriodKey>([
|
||||||
|
'today',
|
||||||
|
'week',
|
||||||
|
'month',
|
||||||
|
'quarter',
|
||||||
|
'year',
|
||||||
|
'custom',
|
||||||
|
]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const auth = useAuthStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const data = ref<ReportsDashboardDto | null>(null);
|
const data = ref<ReportsDashboardDto | null>(null);
|
||||||
@@ -57,10 +73,33 @@ const drillLoading = ref(false);
|
|||||||
const drillError = ref('');
|
const drillError = ref('');
|
||||||
const drill = ref<ReportsDrilldownDto | null>(null);
|
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(
|
const currency = computed(
|
||||||
() => data.value?.currency || org.value?.baseCurrency || 'IRR',
|
() => 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 funnelByPipeline = computed(() => {
|
||||||
const rows = data.value?.funnel ?? [];
|
const rows = data.value?.funnel ?? [];
|
||||||
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
|
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
|
||||||
@@ -154,9 +193,14 @@ function openDeal(link: string) {
|
|||||||
void router.push(link);
|
void router.push(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPeriod(key: PeriodKey) {
|
function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) {
|
||||||
period.value = key;
|
period.value = key;
|
||||||
showCustomDates.value = key === 'custom';
|
showCustomDates.value = key === 'custom';
|
||||||
|
if (!opts?.keepView) {
|
||||||
|
activeViewId.value = null;
|
||||||
|
shareNote.value = '';
|
||||||
|
syncViewQuery(null);
|
||||||
|
}
|
||||||
if (key === 'custom') return;
|
if (key === 'custom') return;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const j = dateToJalali(now);
|
const j = dateToJalali(now);
|
||||||
@@ -186,8 +230,232 @@ function applyPeriod(key: PeriodKey) {
|
|||||||
function openCustom() {
|
function openCustom() {
|
||||||
period.value = 'custom';
|
period.value = 'custom';
|
||||||
showCustomDates.value = true;
|
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() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -571,10 +839,6 @@ const activityMax = computed(() =>
|
|||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
applyPeriod('month');
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -584,6 +848,14 @@ onMounted(() => {
|
|||||||
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<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
|
<UiButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:disabled="!data"
|
:disabled="!data"
|
||||||
@@ -614,10 +886,45 @@ onMounted(() => {
|
|||||||
<UiIcon name="calendar" :size="16" />
|
<UiIcon name="calendar" :size="16" />
|
||||||
<span>{{ rangeLabel() }}</span>
|
<span>{{ rangeLabel() }}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="shareNote" class="ok-note">{{ shareNote }}</p>
|
||||||
|
<p v-if="viewsError" class="error">{{ viewsError }}</p>
|
||||||
|
|
||||||
<!-- Custom date inputs -->
|
<!-- 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="from" placeholder="از تاریخ" />
|
||||||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||||||
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
|
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
|
||||||
@@ -954,10 +1261,75 @@ onMounted(() => {
|
|||||||
<p v-else class="empty-msg">موردی یافت نشد.</p>
|
<p v-else class="empty-msg">موردی یافت نشد.</p>
|
||||||
</template>
|
</template>
|
||||||
</UiModal>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.deep-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -3051,7 +3051,25 @@ export type SavedViewScope =
|
|||||||
| "people"
|
| "people"
|
||||||
| "companies"
|
| "companies"
|
||||||
| "activities"
|
| "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 {
|
export interface SavedViewDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user