dashboard
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Reserve>,
|
||||
private readonly salonsService: SalonsService,
|
||||
private readonly salonPolicy: SalonPolicy,
|
||||
) {}
|
||||
|
||||
async getSalonDashboard(
|
||||
salonId: string,
|
||||
user: AuthUser,
|
||||
): Promise<SalonDashboard> {
|
||||
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<string, CustomerVisit[]> {
|
||||
const grouped: Record<string, CustomerVisit[]> = {};
|
||||
|
||||
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<string, CustomerVisit[]>,
|
||||
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<string, CustomerVisit[]>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user