diff --git a/src/app.module.ts b/src/app.module.ts index fafa45c..8839179 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { UsersModule } from './users/users.module'; import { PaymentsModule } from './payments/payments.module'; import { ProvincesModule } from './provinces/provinces.module'; import { UploaderModule } from './uploader/uploader.module'; +import { DashboardModule } from './dashboard/dashboard.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { UploaderModule } from './uploader/uploader.module'; PaymentsModule, ProvincesModule, UploaderModule, + DashboardModule, ], controllers: [], providers: [], diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..2a7c5ce --- /dev/null +++ b/src/dashboard/dashboard.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Param, ParseUUIDPipe, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { UserRole } from '../users/enums/user-role.enum'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { DashboardService } from './dashboard.service'; +import { SalonDashboardDto } from './dto/salon-dashboard.dto'; + +@ApiTags('Dashboard') +@ApiBearerAuth(SWAGGER_JWT_AUTH) +@ApiStandardController() +@Controller('dashboard') +@UseGuards(RolesGuard, PermissionsGuard) +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.RESERVES_READ) + @ApiStandardOkResponse(SalonDashboardDto) + @Get('salon/:salonId') + getSalonDashboard( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.dashboardService.getSalonDashboard(salonId, user); + } +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..ea119e0 --- /dev/null +++ b/src/dashboard/dashboard.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { SalonsModule } from '../salons/salons.module'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Reserve]), + SalonsModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..1b1765e --- /dev/null +++ b/src/dashboard/dashboard.service.ts @@ -0,0 +1,313 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { SalonPolicy } from '../authorization/policies/salon.policy'; +import { ReserveItem } from '../reserves/entities/reserve-item.entity'; +import { Reserve } from '../reserves/entities/reserve.entity'; +import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; +import { SalonsService } from '../salons/salons.service'; +import { + SalonDashboard, + SalonDashboardStats, +} from './interfaces/salon-dashboard.interface'; + +const DEFAULT_RENEWAL_DAYS = 30; +const AT_RISK_GRACE_DAYS = 15; +const RETURNABLE_GRACE_DAYS = 30; + +interface CustomerVisit { + appointmentDate: string; + renewalDays: number; + totalAmount: number; +} + +interface CustomerSegmentCounts { + active: number; + atRisk: number; + returnable: number; +} + +@Injectable() +export class DashboardService { + constructor( + @InjectRepository(Reserve) + private readonly reservesRepository: Repository, + private readonly salonsService: SalonsService, + private readonly salonPolicy: SalonPolicy, + ) {} + + async getSalonDashboard( + salonId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.salonPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + const completedReserves = await this.reservesRepository.find({ + where: { salonId, status: ReserveStatus.COMPLETED }, + relations: { items: { serviceSkill: true } }, + order: { appointmentDate: 'ASC' }, + }); + + const customerVisits = this.groupVisitsByCustomer(completedReserves); + const now = new Date(); + const currentMonthStart = this.startOfMonth(now); + const nextMonthStart = this.startOfNextMonth(now); + const previousMonthStart = this.startOfPreviousMonth(now); + + const monthlyIncome = this.sumIncomeInRange( + completedReserves, + currentMonthStart, + nextMonthStart, + ); + const previousMonthlyIncome = this.sumIncomeInRange( + completedReserves, + previousMonthStart, + currentMonthStart, + ); + + const currentSegments = this.countSegments(customerVisits, now); + const previousReferenceDate = this.addDays(currentMonthStart, -1); + const previousSegments = this.countSegments( + customerVisits, + previousReferenceDate, + ); + + const { returnedCount, returnedIncome } = this.countReturnedThisMonth( + customerVisits, + currentMonthStart, + nextMonthStart, + ); + + const totalCustomers = Object.keys(customerVisits).length; + const retentionGoal = + totalCustomers === 0 + ? 0 + : Math.round((currentSegments.active / totalCustomers) * 100); + + const stats: SalonDashboardStats = { + monthlyIncome, + monthlyIncomeTrend: this.percentTrend( + monthlyIncome, + previousMonthlyIncome, + ), + activeCustomers: currentSegments.active, + activeCustomersTrend: this.percentTrend( + currentSegments.active, + previousSegments.active, + ), + atRiskCustomers: currentSegments.atRisk, + atRiskTrend: this.percentTrend( + currentSegments.atRisk, + previousSegments.atRisk, + ), + returnableCustomers: currentSegments.returnable, + returnableTrend: this.percentTrend( + currentSegments.returnable, + previousSegments.returnable, + ), + returnedThisMonth: returnedCount, + incomeThisMonth: returnedIncome, + }; + + return { + stats, + retentionGoal, + revenueChart: this.buildRevenueChart(completedReserves, now), + }; + } + + private groupVisitsByCustomer( + reserves: Reserve[], + ): Record { + const grouped: Record = {}; + + for (const reserve of reserves) { + const renewalDays = this.resolveRenewalDays(reserve.items); + const visit: CustomerVisit = { + appointmentDate: reserve.appointmentDate, + renewalDays, + totalAmount: reserve.totalAmount, + }; + + if (!grouped[reserve.customerId]) { + grouped[reserve.customerId] = []; + } + grouped[reserve.customerId].push(visit); + } + + return grouped; + } + + private resolveRenewalDays(items: ReserveItem[]): number { + if (!items?.length) { + return DEFAULT_RENEWAL_DAYS; + } + + const renewalDays = items + .map((item) => item.serviceSkill?.renewalDays ?? DEFAULT_RENEWAL_DAYS) + .filter((value) => value > 0); + + if (renewalDays.length === 0) { + return DEFAULT_RENEWAL_DAYS; + } + + return Math.max(...renewalDays); + } + + private countSegments( + customerVisits: Record, + referenceDate: Date, + ): CustomerSegmentCounts { + const counts: CustomerSegmentCounts = { + active: 0, + atRisk: 0, + returnable: 0, + }; + + for (const visits of Object.values(customerVisits)) { + const lastVisit = visits[visits.length - 1]; + const daysSince = this.daysBetween( + this.parseDate(lastVisit.appointmentDate), + referenceDate, + ); + const segment = this.resolveSegment(daysSince, lastVisit.renewalDays); + + if (segment === 'active') { + counts.active += 1; + } else if (segment === 'at-risk') { + counts.atRisk += 1; + } else if (segment === 'returnable') { + counts.returnable += 1; + } + } + + return counts; + } + + private resolveSegment( + daysSince: number, + renewalDays: number, + ): 'active' | 'at-risk' | 'returnable' | 'lost' { + if (daysSince <= renewalDays) { + return 'active'; + } + + const atRiskLimit = renewalDays + AT_RISK_GRACE_DAYS; + if (daysSince <= atRiskLimit) { + return 'at-risk'; + } + + const returnableLimit = atRiskLimit + RETURNABLE_GRACE_DAYS; + if (daysSince <= returnableLimit) { + return 'returnable'; + } + + return 'lost'; + } + + private countReturnedThisMonth( + customerVisits: Record, + monthStart: Date, + nextMonthStart: Date, + ): { returnedCount: number; returnedIncome: number } { + let returnedCount = 0; + let returnedIncome = 0; + + for (const visits of Object.values(customerVisits)) { + if (visits.length < 2) { + continue; + } + + const lastVisit = visits[visits.length - 1]; + const previousVisit = visits[visits.length - 2]; + const lastVisitDate = this.parseDate(lastVisit.appointmentDate); + + if (lastVisitDate < monthStart || lastVisitDate >= nextMonthStart) { + continue; + } + + const gapDays = this.daysBetween( + this.parseDate(previousVisit.appointmentDate), + lastVisitDate, + ); + + if (gapDays > previousVisit.renewalDays) { + returnedCount += 1; + returnedIncome += lastVisit.totalAmount; + } + } + + return { returnedCount, returnedIncome }; + } + + private sumIncomeInRange( + reserves: Reserve[], + start: Date, + end: Date, + ): number { + return reserves.reduce((sum, reserve) => { + const appointmentDate = this.parseDate(reserve.appointmentDate); + if (appointmentDate >= start && appointmentDate < end) { + return sum + reserve.totalAmount; + } + return sum; + }, 0); + } + + private buildRevenueChart(reserves: Reserve[], referenceDate: Date): number[] { + const chart: number[] = []; + + for (let offset = 11; offset >= 0; offset -= 1) { + const monthDate = this.startOfMonth(this.addMonths(referenceDate, -offset)); + const nextMonth = this.startOfNextMonth(monthDate); + chart.push(this.sumIncomeInRange(reserves, monthDate, nextMonth)); + } + + return chart; + } + + private percentTrend(current: number, previous: number): number { + if (previous === 0) { + return current > 0 ? 100 : 0; + } + + return Math.round(((current - previous) / previous) * 100); + } + + private parseDate(value: string): Date { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); + } + + private daysBetween(from: Date, to: Date): number { + const start = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const end = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.floor((end - start) / (1000 * 60 * 60 * 24)); + } + + private startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); + } + + private startOfNextMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth() + 1, 1); + } + + private startOfPreviousMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth() - 1, 1); + } + + private addMonths(date: Date, months: number): Date { + return new Date(date.getFullYear(), date.getMonth() + months, 1); + } + + private addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } +} diff --git a/src/dashboard/dto/salon-dashboard.dto.ts b/src/dashboard/dto/salon-dashboard.dto.ts new file mode 100644 index 0000000..3db420c --- /dev/null +++ b/src/dashboard/dto/salon-dashboard.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SalonDashboardStatsDto { + @ApiProperty({ example: 48_500_000 }) + monthlyIncome: number; + + @ApiProperty({ example: 12 }) + monthlyIncomeTrend: number; + + @ApiProperty({ example: 186 }) + activeCustomers: number; + + @ApiProperty({ example: 8 }) + activeCustomersTrend: number; + + @ApiProperty({ example: 23 }) + atRiskCustomers: number; + + @ApiProperty({ example: -5 }) + atRiskTrend: number; + + @ApiProperty({ example: 41 }) + returnableCustomers: number; + + @ApiProperty({ example: 15 }) + returnableTrend: number; + + @ApiProperty({ example: 27 }) + returnedThisMonth: number; + + @ApiProperty({ example: 24_000_000 }) + incomeThisMonth: number; +} + +export class SalonDashboardDto { + @ApiProperty({ type: SalonDashboardStatsDto }) + stats: SalonDashboardStatsDto; + + @ApiProperty({ example: 74, description: 'Retention rate percentage' }) + retentionGoal: number; + + @ApiProperty({ + type: [Number], + example: [12_000_000, 18_000_000, 15_000_000], + description: 'Monthly revenue for the last 12 months', + }) + revenueChart: number[]; +} diff --git a/src/dashboard/interfaces/salon-dashboard.interface.ts b/src/dashboard/interfaces/salon-dashboard.interface.ts new file mode 100644 index 0000000..a89c4f7 --- /dev/null +++ b/src/dashboard/interfaces/salon-dashboard.interface.ts @@ -0,0 +1,18 @@ +export interface SalonDashboardStats { + monthlyIncome: number; + monthlyIncomeTrend: number; + activeCustomers: number; + activeCustomersTrend: number; + atRiskCustomers: number; + atRiskTrend: number; + returnableCustomers: number; + returnableTrend: number; + returnedThisMonth: number; + incomeThisMonth: number; +} + +export interface SalonDashboard { + stats: SalonDashboardStats; + retentionGoal: number; + revenueChart: number[]; +} diff --git a/src/database/typeorm-options.ts b/src/database/typeorm-options.ts index bd568e0..ba00d5a 100644 --- a/src/database/typeorm-options.ts +++ b/src/database/typeorm-options.ts @@ -1,8 +1,6 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; export function getTypeOrmOptions(): TypeOrmModuleOptions { - const isProduction = process.env.NODE_ENV === 'production'; - return { type: 'postgres', host: process.env.DB_HOST, @@ -16,6 +14,6 @@ export function getTypeOrmOptions(): TypeOrmModuleOptions { // synchronize breaks shared PostgreSQL enums (reserves_status_enum is used // by both reserves and reserve_status_history). Use migrations instead. synchronize: false, - migrationsRun: isProduction, + migrationsRun: true, }; }