From 35efba132822b93d524740a2a5ca994f35596abc Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sun, 29 Mar 2026 10:35:43 +0100 Subject: [PATCH] Admin Analytics, Occupancy snapshot, Booking stats,Revenue and invoice and Dashboard module --- backend/package-lock.json | 41 +-- .../src/bookings/entities/booking.entity.ts | 53 +++ .../src/bookings/enums/booking-status.enum.ts | 6 + backend/src/bookings/enums/plan-type.enum.ts | 6 + backend/src/dashboard/dashboard.controller.ts | 19 +- backend/src/dashboard/dashboard.module.ts | 11 +- .../src/dashboard/dto/analytics-query.dto.ts | 22 ++ .../providers/admin-analytics.provider.ts | 330 ++++++++++++++++++ .../providers/member-dashboard.provider.ts | 15 + .../src/payments/entities/invoice.entity.ts | 51 +++ .../src/payments/entities/payment.entity.ts | 51 +++ .../src/payments/enums/invoice-status.enum.ts | 6 + backend/src/users/enums/userRoles.enum.ts | 2 + .../workspaces/entities/workspace.entity.ts | 4 + 14 files changed, 583 insertions(+), 34 deletions(-) create mode 100644 backend/src/bookings/entities/booking.entity.ts create mode 100644 backend/src/bookings/enums/booking-status.enum.ts create mode 100644 backend/src/bookings/enums/plan-type.enum.ts create mode 100644 backend/src/dashboard/dto/analytics-query.dto.ts create mode 100644 backend/src/dashboard/providers/admin-analytics.provider.ts create mode 100644 backend/src/dashboard/providers/member-dashboard.provider.ts create mode 100644 backend/src/payments/entities/invoice.entity.ts create mode 100644 backend/src/payments/entities/payment.entity.ts create mode 100644 backend/src/payments/enums/invoice-status.enum.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index efb4abf6..cfbc4a9d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -3761,7 +3761,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -3808,7 +3807,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -3889,7 +3887,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -4293,6 +4290,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -4333,6 +4331,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -4347,6 +4346,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.6" }, @@ -5528,7 +5528,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5555,7 +5554,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5693,7 +5691,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5883,7 +5880,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -6280,7 +6276,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6328,7 +6323,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6668,7 +6662,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -7022,7 +7015,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7127,7 +7119,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -7166,7 +7157,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -7402,7 +7392,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7458,15 +7447,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -8588,7 +8575,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8645,7 +8631,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10472,7 +10457,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11538,7 +11522,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -13000,7 +12983,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -13324,7 +13306,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13465,7 +13446,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -13725,7 +13705,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14287,7 +14266,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14359,7 +14337,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15496,7 +15473,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15674,7 +15650,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15899,7 +15874,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16390,6 +16364,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -16407,6 +16382,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16420,6 +16396,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -16429,6 +16406,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -16438,6 +16416,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -16450,6 +16429,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16621,7 +16601,6 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", diff --git a/backend/src/bookings/entities/booking.entity.ts b/backend/src/bookings/entities/booking.entity.ts new file mode 100644 index 00000000..2a16178a --- /dev/null +++ b/backend/src/bookings/entities/booking.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { BookingStatus } from '../enums/booking-status.enum'; + +@Entity('bookings') +export class Booking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column('uuid') + workspaceId: string; + + @ManyToOne(() => Workspace, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Workspace; + + @Column({ type: 'enum', enum: BookingStatus, default: BookingStatus.PENDING }) + status: BookingStatus; + + @Column('int') + seatCount: number; + + @Column('bigint') + totalAmountKobo: number; + + @Column('timestamptz') + startDate: Date; + + @Column('timestamptz') + endDate: Date; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/bookings/enums/booking-status.enum.ts b/backend/src/bookings/enums/booking-status.enum.ts new file mode 100644 index 00000000..4a7e1e2e --- /dev/null +++ b/backend/src/bookings/enums/booking-status.enum.ts @@ -0,0 +1,6 @@ +export enum BookingStatus { + PENDING = 'PENDING', + CONFIRMED = 'CONFIRMED', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} diff --git a/backend/src/bookings/enums/plan-type.enum.ts b/backend/src/bookings/enums/plan-type.enum.ts new file mode 100644 index 00000000..b9fd2125 --- /dev/null +++ b/backend/src/bookings/enums/plan-type.enum.ts @@ -0,0 +1,6 @@ +export enum PlanType { + HOURLY = 'HOURLY', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts index 1197d063..d83c2a2b 100644 --- a/backend/src/dashboard/dashboard.controller.ts +++ b/backend/src/dashboard/dashboard.controller.ts @@ -7,6 +7,8 @@ import { UseGuards, } from '@nestjs/common'; import { DashboardService } from './dashboard.service'; +import { AdminAnalyticsProvider } from './providers/admin-analytics.provider'; +import { AnalyticsQueryDto } from './dto/analytics-query.dto'; import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; import { RolesGuard } from '../auth/guard/roles.guard'; import { Roles } from '../auth/decorators/roles.decorators'; @@ -19,7 +21,10 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; @ApiBearerAuth() @Controller('dashboard') export class DashboardController { - constructor(private readonly dashboardService: DashboardService) {} + constructor( + private readonly dashboardService: DashboardService, + private readonly adminAnalyticsProvider: AdminAnalyticsProvider, + ) {} @Get('stats') @HttpCode(HttpStatus.OK) @@ -60,4 +65,16 @@ export class DashboardController { ); return { success: true, ...data }; } + + @Get('admin/analytics') + @HttpCode(HttpStatus.OK) + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN, UserRole.STAFF) + @UseGuards(JwtAuthGuard, RolesGuard) + async getAdminAnalytics(@Query() query: AnalyticsQueryDto) { + const data = await this.adminAnalyticsProvider.getFullAdminDashboard( + query.from, + query.to, + ); + return { success: true, data }; + } } diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts index 77b3aaa2..98ff1e37 100644 --- a/backend/src/dashboard/dashboard.module.ts +++ b/backend/src/dashboard/dashboard.module.ts @@ -2,12 +2,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; +import { AdminAnalyticsProvider } from './providers/admin-analytics.provider'; +import { MemberDashboardProvider } from './providers/member-dashboard.provider'; import { User } from '../users/entities/user.entity'; import { NewsletterSubscriber } from '../newsletter/entities/newsletter.entity'; +import { Workspace } from '../workspaces/entities/workspace.entity'; +import { WorkspaceLog } from '../workspace-tracking/entities/workspace-log.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { Invoice } from '../payments/entities/invoice.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User, NewsletterSubscriber])], + imports: [TypeOrmModule.forFeature([Booking, Payment, Invoice, WorkspaceLog, Workspace, User, NewsletterSubscriber])], controllers: [DashboardController], - providers: [DashboardService], + providers: [DashboardService, AdminAnalyticsProvider, MemberDashboardProvider], }) export class DashboardModule {} diff --git a/backend/src/dashboard/dto/analytics-query.dto.ts b/backend/src/dashboard/dto/analytics-query.dto.ts new file mode 100644 index 00000000..06d7785b --- /dev/null +++ b/backend/src/dashboard/dto/analytics-query.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, IsDateString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class AnalyticsQueryDto { + @ApiPropertyOptional({ + description: 'Start date for analytics period (ISO string)', + example: '2024-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsString() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ + description: 'End date for analytics period (ISO string)', + example: '2024-12-31T23:59:59.999Z', + }) + @IsOptional() + @IsString() + @IsDateString() + to?: string; +} diff --git a/backend/src/dashboard/providers/admin-analytics.provider.ts b/backend/src/dashboard/providers/admin-analytics.provider.ts new file mode 100644 index 00000000..57f45183 --- /dev/null +++ b/backend/src/dashboard/providers/admin-analytics.provider.ts @@ -0,0 +1,330 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Workspace } from '../../workspaces/entities/workspace.entity'; +import { WorkspaceLog } from '../../workspace-tracking/entities/workspace-log.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Invoice } from '../../payments/entities/invoice.entity'; +import { BookingStatus } from '../../bookings/enums/booking-status.enum'; + +@Injectable() +export class AdminAnalyticsProvider { + constructor( + @InjectRepository(Workspace) + private readonly workspaceRepository: Repository, + @InjectRepository(WorkspaceLog) + private readonly workspaceLogRepository: Repository, + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepository: Repository, + ) {} + + async getOccupancySnapshot() { + // Get total seats from all active workspaces + const activeWorkspacesResult = await this.workspaceRepository + .createQueryBuilder('workspace') + .select('COUNT(workspace.id)', 'activeWorkspaces') + .addSelect('SUM(workspace.totalSeats)', 'totalSeats') + .where('workspace.isActive = :isActive', { isActive: true }) + .getRawOne(); + + const activeWorkspaces = parseInt(activeWorkspacesResult.activeWorkspaces) || 0; + const totalSeats = parseInt(activeWorkspacesResult.totalSeats) || 0; + + // Get occupied seats (check-ins without check-outs) + const occupiedSeatsResult = await this.workspaceLogRepository + .createQueryBuilder('log') + .select('COUNT(log.id)', 'occupiedSeats') + .where('log.checkedOutAt IS NULL') + .getRawOne(); + + const occupiedSeats = parseInt(occupiedSeatsResult.occupiedSeats) || 0; + const availableSeats = totalSeats - occupiedSeats; + const occupancyPercent = totalSeats > 0 ? Math.round((occupiedSeats / totalSeats) * 100) : 0; + + return { + totalSeats, + occupiedSeats, + availableSeats, + occupancyPercent, + activeWorkspaces, + }; + } + + async getRevenueStats(from?: string, to?: string) { + const now = new Date(); + const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); + + // Base query for successful payments + const baseQuery = this.paymentRepository + .createQueryBuilder('payment') + .where('payment.status = :status', { status: 'SUCCESSFUL' }); + + // Apply date range filters if provided + if (from) { + baseQuery.andWhere('payment.createdAt >= :from', { from }); + } + if (to) { + baseQuery.andWhere('payment.createdAt <= :to', { to }); + } + + // Get total revenue (with date range filters) + const totalQuery = baseQuery.clone(); + const totalResult = await totalQuery + .select('SUM(payment.amountKobo)', 'total') + .getRawOne(); + const total = parseInt(totalResult.total) || 0; + + // Get this month revenue (without date range filters for month calculation) + const thisMonthQuery = this.paymentRepository + .createQueryBuilder('payment') + .where('payment.status = :status', { status: 'SUCCESSFUL' }) + .andWhere('payment.createdAt >= :thisMonthStart', { thisMonthStart: currentMonthStart }); + + if (from && new Date(from) > currentMonthStart) { + thisMonthQuery.andWhere('payment.createdAt >= :from', { from }); + } + if (to) { + thisMonthQuery.andWhere('payment.createdAt <= :to', { to }); + } + + const thisMonthResult = await thisMonthQuery + .select('SUM(payment.amountKobo)', 'thisMonth') + .getRawOne(); + const thisMonthRevenue = parseInt(thisMonthResult.thisMonth) || 0; + + // Get last month revenue (without date range filters for month calculation) + const lastMonthQuery = this.paymentRepository + .createQueryBuilder('payment') + .where('payment.status = :status', { status: 'SUCCESSFUL' }) + .andWhere('payment.createdAt >= :lastMonthStart', { lastMonthStart }) + .andWhere('payment.createdAt <= :lastMonthEnd', { lastMonthEnd }); + + const lastMonthResult = await lastMonthQuery + .select('SUM(payment.amountKobo)', 'lastMonth') + .getRawOne(); + const lastMonthRevenue = parseInt(lastMonthResult.lastMonth) || 0; + + // Get 6-month trend + const trendQuery = this.paymentRepository + .createQueryBuilder('payment') + .select("DATE_TRUNC('month', payment.createdAt)", 'month') + .addSelect('SUM(payment.amountKobo)', 'totalKobo') + .where('payment.status = :status', { status: 'SUCCESSFUL' }) + .andWhere('payment.createdAt >= NOW() - INTERVAL \'6 months\'') + .groupBy("DATE_TRUNC('month', payment.createdAt)") + .orderBy("DATE_TRUNC('month', payment.createdAt)", 'ASC'); + + // Apply date range filters to trend if provided + if (from) { + trendQuery.andWhere('payment.createdAt >= :from', { from }); + } + if (to) { + trendQuery.andWhere('payment.createdAt <= :to', { to }); + } + + const trendResults = await trendQuery.getRawMany(); + const trend = trendResults.map(result => ({ + month: result.month, + totalKobo: parseInt(result.totalKobo) || 0, + totalNaira: Math.round((parseInt(result.totalKobo) || 0) / 100), + })); + + return { + total, + thisMonth: thisMonthRevenue, + lastMonth: lastMonthRevenue, + trend, + }; + } + + async getBookingStats(from?: string, to?: string) { + // Get booking counts by status + const statusQuery = this.bookingRepository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(booking.id)', 'count') + .groupBy('booking.status'); + + if (from) { + statusQuery.andWhere('booking.createdAt >= :from', { from }); + } + if (to) { + statusQuery.andWhere('booking.createdAt <= :to', { to }); + } + + const statusResults = await statusQuery.getRawMany(); + const byStatus = statusResults.reduce((acc, result) => { + acc[result.status] = parseInt(result.count); + return acc; + }, {} as Record); + + // Get monthly trend for last 6 months + const trendQuery = this.bookingRepository + .createQueryBuilder('booking') + .select("DATE_TRUNC('month', booking.createdAt)", 'month') + .addSelect('COUNT(booking.id)', 'count') + .where('booking.createdAt >= NOW() - INTERVAL \'6 months\'') + .groupBy("DATE_TRUNC('month', booking.createdAt)") + .orderBy("DATE_TRUNC('month', booking.createdAt)", 'ASC'); + + if (from) { + trendQuery.andWhere('booking.createdAt >= :from', { from }); + } + if (to) { + trendQuery.andWhere('booking.createdAt <= :to', { to }); + } + + const trendResults = await trendQuery.getRawMany(); + const trend = trendResults.map(result => ({ + month: result.month, + count: parseInt(result.count), + })); + + return { + byStatus, + trend, + }; + } + + async getTopWorkspaces(limit = 5, from?: string, to?: string) { + const query = this.workspaceRepository + .createQueryBuilder('workspace') + .leftJoin('booking', 'booking', 'booking.workspaceId = workspace.id') + .leftJoin('payment', 'payment', 'payment.bookingId = booking.id AND payment.status = :paymentStatus', { paymentStatus: 'SUCCESSFUL' }) + .select('workspace.id', 'id') + .addSelect('workspace.name', 'name') + .addSelect('COUNT(DISTINCT booking.id)', 'bookings') + .addSelect('COALESCE(SUM(payment.amountKobo), 0)', 'revenueKobo') + .where('workspace.isActive = :isActive', { isActive: true }) + .groupBy('workspace.id, workspace.name') + .orderBy('COUNT(DISTINCT booking.id)', 'DESC') + .limit(limit); + + if (from) { + query.andWhere('booking.createdAt >= :from', { from }); + } + if (to) { + query.andWhere('booking.createdAt <= :to', { to }); + } + + const results = await query.getRawMany(); + return results.map(result => ({ + id: result.id, + name: result.name, + bookings: parseInt(result.bookings) || 0, + revenueKobo: parseInt(result.revenueKobo) || 0, + })); + } + + async getTopMembers(limit = 5, from?: string, to?: string) { + const query = this.paymentRepository + .createQueryBuilder('payment') + .leftJoin('user', 'user', 'user.id = payment.userId') + .select('user.id', 'id') + .addSelect("user.firstname || ' ' || user.lastname", 'fullName') + .addSelect('SUM(payment.amountKobo)', 'totalKobo') + .where('payment.status = :status', { status: 'SUCCESSFUL' }) + .groupBy('user.id, user.firstname, user.lastname') + .orderBy('SUM(payment.amountKobo)', 'DESC') + .limit(limit); + + if (from) { + query.andWhere('payment.createdAt >= :from', { from }); + } + if (to) { + query.andWhere('payment.createdAt <= :to', { to }); + } + + const results = await query.getRawMany(); + return results.map(result => ({ + id: result.id, + fullName: result.fullName || 'Unknown User', + totalKobo: parseInt(result.totalKobo) || 0, + })); + } + + async getInvoiceStats(from?: string, to?: string) { + const baseQuery = this.invoiceRepository.createQueryBuilder('invoice'); + + // Apply date range filters if provided + if (from) { + baseQuery.andWhere('invoice.createdAt >= :from', { from }); + } + if (to) { + baseQuery.andWhere('invoice.createdAt <= :to', { to }); + } + + // Get total count + const totalQuery = baseQuery.clone(); + const totalResult = await totalQuery + .select('COUNT(invoice.id)', 'total') + .getRawOne(); + const total = parseInt(totalResult.total) || 0; + + // Get paid count + const paidQuery = baseQuery.clone(); + const paidResult = await paidQuery + .select('COUNT(invoice.id)', 'paid') + .where('invoice.status = :status', { status: 'PAID' }) + .getRawOne(); + const paid = parseInt(paidResult.paid) || 0; + + // Get pending count + const pendingQuery = baseQuery.clone(); + const pendingResult = await pendingQuery + .select('COUNT(invoice.id)', 'pending') + .where('invoice.status = :status', { status: 'PENDING' }) + .getRawOne(); + const pending = parseInt(pendingResult.pending) || 0; + + // Get total amount + const amountQuery = baseQuery.clone(); + const amountResult = await amountQuery + .select('SUM(invoice.amountKobo)', 'totalAmountKobo') + .getRawOne(); + const totalAmountKobo = parseInt(amountResult.totalAmountKobo) || 0; + const totalAmountNaira = Math.round(totalAmountKobo / 100); + + return { + total, + paid, + pending, + totalAmountKobo, + totalAmountNaira, + }; + } + + async getFullAdminDashboard(from?: string, to?: string) { + const [ + revenue, + bookings, + topWorkspaces, + topMembers, + invoices, + occupancy, + ] = await Promise.all([ + this.getRevenueStats(from, to), + this.getBookingStats(from, to), + this.getTopWorkspaces(5, from, to), + this.getTopMembers(5, from, to), + this.getInvoiceStats(from, to), + this.getOccupancySnapshot(), + ]); + + return { + revenue, + bookings, + topWorkspaces, + topMembers, + invoices, + occupancy, + }; + } +} diff --git a/backend/src/dashboard/providers/member-dashboard.provider.ts b/backend/src/dashboard/providers/member-dashboard.provider.ts new file mode 100644 index 00000000..e72dfd2a --- /dev/null +++ b/backend/src/dashboard/providers/member-dashboard.provider.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MemberDashboardProvider { + // Stub implementation for MemberDashboardProvider + // To be implemented in Issue Parking Reservation System #74 + + async getMemberDashboard(userId: string) { + // Placeholder implementation + return { + message: 'MemberDashboardProvider stub - to be implemented', + userId, + }; + } +} diff --git a/backend/src/payments/entities/invoice.entity.ts b/backend/src/payments/entities/invoice.entity.ts new file mode 100644 index 00000000..2bf301d5 --- /dev/null +++ b/backend/src/payments/entities/invoice.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +@Entity('invoices') +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column('uuid', { nullable: true }) + bookingId: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + invoiceNumber: string; + + @Column('bigint') + amountKobo: number; + + @Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.PENDING }) + status: InvoiceStatus; + + @Column('timestamptz', { nullable: true }) + dueDate: Date; + + @Column('timestamptz', { nullable: true }) + paidAt: Date; + + @Column({ type: 'text', nullable: true }) + description: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts new file mode 100644 index 00000000..1fc64386 --- /dev/null +++ b/backend/src/payments/entities/payment.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum PaymentStatus { + PENDING = 'PENDING', + SUCCESSFUL = 'SUCCESSFUL', + FAILED = 'FAILED', + REFUNDED = 'REFUNDED', +} + +@Entity('payments') +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column('uuid', { nullable: true }) + bookingId: string; + + @Column('bigint') + amountKobo: number; + + @Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING }) + status: PaymentStatus; + + @Column({ nullable: true }) + reference: string; + + @Column({ nullable: true }) + provider: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/src/payments/enums/invoice-status.enum.ts b/backend/src/payments/enums/invoice-status.enum.ts new file mode 100644 index 00000000..31fe6df8 --- /dev/null +++ b/backend/src/payments/enums/invoice-status.enum.ts @@ -0,0 +1,6 @@ +export enum InvoiceStatus { + PENDING = 'PENDING', + PAID = 'PAID', + OVERDUE = 'OVERDUE', + CANCELLED = 'CANCELLED', +} diff --git a/backend/src/users/enums/userRoles.enum.ts b/backend/src/users/enums/userRoles.enum.ts index 9ebd4b18..8e271dac 100644 --- a/backend/src/users/enums/userRoles.enum.ts +++ b/backend/src/users/enums/userRoles.enum.ts @@ -1,4 +1,6 @@ export enum UserRole { USER = 'user', + STAFF = 'staff', ADMIN = 'admin', + SUPER_ADMIN = 'super_admin', } diff --git a/backend/src/workspaces/entities/workspace.entity.ts b/backend/src/workspaces/entities/workspace.entity.ts index 2f735f0d..bd95128a 100644 --- a/backend/src/workspaces/entities/workspace.entity.ts +++ b/backend/src/workspaces/entities/workspace.entity.ts @@ -7,6 +7,7 @@ import { OneToMany, } from 'typeorm'; import { WorkspaceType } from '../enums/workspace-type.enum'; +import { Booking } from '../../bookings/entities/booking.entity'; @Entity('workspaces') export class Workspace { @@ -46,4 +47,7 @@ export class Workspace { @UpdateDateColumn() updatedAt: Date; + + @OneToMany(() => Booking, booking => booking.workspace) + bookings: Booking[]; }