dashboard
This commit is contained in:
@@ -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: [],
|
||||||
|
|||||||
@@ -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';
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user