diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f9b8380..ee6c3c0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index dbd4c95..a55f8ec 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -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; + + @IsOptional() + @IsBoolean() + isShared?: boolean; +} + +class UpdateReportViewDto { + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + + @IsOptional() + @IsObject() + filters?: Record; + + @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 }; + } } diff --git a/apps/api/src/reports/reports.module.ts b/apps/api/src/reports/reports.module.ts index fd97715..b04e145 100644 --- a/apps/api/src/reports/reports.module.ts +++ b/apps/api/src/reports/reports.module.ts @@ -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], }) diff --git a/apps/api/src/saved-views/saved-views.controller.ts b/apps/api/src/saved-views/saved-views.controller.ts index 5e5081d..947630b 100644 --- a/apps/api/src/saved-views/saved-views.controller.ts +++ b/apps/api/src/saved-views/saved-views.controller.ts @@ -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; @@ -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 }, diff --git a/apps/api/src/saved-views/saved-views.service.ts b/apps/api/src/saved-views/saved-views.service.ts index ca7c0d5..5d98d2e 100644 --- a/apps/api/src/saved-views/saved-views.service.ts +++ b/apps/api/src/saved-views/saved-views.service.ts @@ -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 { + 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 { 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 { 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; diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index 5b4fc79..de355b9 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -1,8 +1,9 @@