dashboard

This commit is contained in:
2026-07-07 20:20:47 +03:30
parent 45ab6a8dda
commit ba3d8f438e
7 changed files with 439 additions and 3 deletions
+2
View File
@@ -18,6 +18,7 @@ import { UsersModule } from './users/users.module';
import { PaymentsModule } from './payments/payments.module'; import { PaymentsModule } from './payments/payments.module';
import { ProvincesModule } from './provinces/provinces.module'; import { ProvincesModule } from './provinces/provinces.module';
import { UploaderModule } from './uploader/uploader.module'; import { UploaderModule } from './uploader/uploader.module';
import { DashboardModule } from './dashboard/dashboard.module';
@Module({ @Module({
imports: [ imports: [
@@ -41,6 +42,7 @@ import { UploaderModule } from './uploader/uploader.module';
PaymentsModule, PaymentsModule,
ProvincesModule, ProvincesModule,
UploaderModule, UploaderModule,
DashboardModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+37
View File
@@ -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);
}
}
+20
View File
@@ -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 {}
+313
View File
@@ -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;
}
}
+48
View File
@@ -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 -3
View File
@@ -1,8 +1,6 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export function getTypeOrmOptions(): TypeOrmModuleOptions { export function getTypeOrmOptions(): TypeOrmModuleOptions {
const isProduction = process.env.NODE_ENV === 'production';
return { return {
type: 'postgres', type: 'postgres',
host: process.env.DB_HOST, host: process.env.DB_HOST,
@@ -16,6 +14,6 @@ export function getTypeOrmOptions(): TypeOrmModuleOptions {
// synchronize breaks shared PostgreSQL enums (reserves_status_enum is used // synchronize breaks shared PostgreSQL enums (reserves_status_enum is used
// by both reserves and reserve_status_history). Use migrations instead. // by both reserves and reserve_status_history). Use migrations instead.
synchronize: false, synchronize: false,
migrationsRun: isProduction, migrationsRun: true,
}; };
} }