From 31d774c4074599ea94cf56a4c714ad30bfeb14d7 Mon Sep 17 00:00:00 2001 From: nanaf6203-bit Date: Mon, 29 Jun 2026 09:52:27 +0000 Subject: [PATCH] feat(dashboard): add CSV export endpoints for bookings, members, revenue, invoices --- backend/src/dashboard/dashboard.controller.ts | 33 ++++- backend/src/dashboard/dashboard.module.ts | 2 + .../dashboard/providers/export.provider.ts | 131 ++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 backend/src/dashboard/providers/export.provider.ts diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts index c1033b5..3f6ae6e 100644 --- a/backend/src/dashboard/dashboard.controller.ts +++ b/backend/src/dashboard/dashboard.controller.ts @@ -4,8 +4,11 @@ import { HttpCode, HttpStatus, Query, + Res, UseGuards, } from '@nestjs/common'; +import { Response } from 'express'; +import { ExportProvider } from './providers/export.provider'; import { DashboardService } from './dashboard.service'; import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; import { RolesGuard } from '../auth/guard/roles.guard'; @@ -20,7 +23,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; @ApiBearerAuth() @Controller('dashboard') export class DashboardController { - constructor(private readonly dashboardService: DashboardService) {} + constructor(private readonly dashboardService: DashboardService, private readonly exportProvider: ExportProvider) {} @Get('stats') @HttpCode(HttpStatus.OK) @@ -158,4 +161,32 @@ export class DashboardController { ); return { success: true, ...data }; } + + @Get('export/bookings') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(JwtAuthGuard, RolesGuard) + exportBookings(@Res() res: Response, @Query('from') from?: string, @Query('to') to?: string) { + return this.exportProvider.exportBookings(res, from, to); + } + + @Get('export/members') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(JwtAuthGuard, RolesGuard) + exportMembers(@Res() res: Response, @Query('from') from?: string, @Query('to') to?: string) { + return this.exportProvider.exportMembers(res, from, to); + } + + @Get('export/revenue') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(JwtAuthGuard, RolesGuard) + exportRevenue(@Res() res: Response, @Query('from') from?: string, @Query('to') to?: string) { + return this.exportProvider.exportRevenue(res, from, to); + } + + @Get('export/invoices') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @UseGuards(JwtAuthGuard, RolesGuard) + exportInvoices(@Res() res: Response, @Query('from') from?: string, @Query('to') to?: string) { + return this.exportProvider.exportInvoices(res, from, to); + } } diff --git a/backend/src/dashboard/dashboard.module.ts b/backend/src/dashboard/dashboard.module.ts index 5cbd3c0..763824e 100644 --- a/backend/src/dashboard/dashboard.module.ts +++ b/backend/src/dashboard/dashboard.module.ts @@ -4,6 +4,7 @@ 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 { ExportProvider } from './providers/export.provider'; import { User } from '../users/entities/user.entity'; import { NewsletterSubscriber } from '../newsletter/entities/newsletter.entity'; import { Booking } from '../bookings/entities/booking.entity'; @@ -29,6 +30,7 @@ import { Workspace } from '../workspaces/entities/workspace.entity'; DashboardService, AdminAnalyticsProvider, MemberDashboardProvider, + ExportProvider, ], }) export class DashboardModule {} diff --git a/backend/src/dashboard/providers/export.provider.ts b/backend/src/dashboard/providers/export.provider.ts new file mode 100644 index 0000000..a59c561 --- /dev/null +++ b/backend/src/dashboard/providers/export.provider.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { User } from '../../users/entities/user.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Invoice } from '../../invoices/entities/invoice.entity'; +import { Response } from 'express'; + +function toCsv(headers: string[], rows: string[][]): string { + const escape = (v: string) => `"${String(v ?? '').replace(/"/g, '""')}"`; + return [headers, ...rows].map((row) => row.map(escape).join(',')).join('\n'); +} + +function dateFilter(from?: string, to?: string) { + if (from && to) return Between(new Date(from), new Date(to)); + if (from) return MoreThanOrEqual(new Date(from)); + if (to) return LessThanOrEqual(new Date(to)); + return undefined; +} + +@Injectable() +export class ExportProvider { + constructor( + @InjectRepository(Booking) private bookingRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Payment) private paymentRepo: Repository, + @InjectRepository(Invoice) private invoiceRepo: Repository, + ) {} + + private setHeaders(res: Response, filename: string) { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + } + + async exportBookings(res: Response, from?: string, to?: string) { + const filter = dateFilter(from, to); + const bookings = await this.bookingRepo.find({ + where: filter ? { createdAt: filter } : {}, + relations: ['user', 'workspace'], + order: { createdAt: 'DESC' }, + }); + + const headers = ['bookingId', 'memberName', 'workspace', 'planType', 'startDate', 'endDate', 'amount', 'status']; + const rows = bookings.map((b) => [ + b.id, + b.user ? `${b.user.firstname} ${b.user.lastname}` : '', + b.workspace?.name ?? '', + b.planType, + b.startDate, + b.endDate, + String(b.totalAmount), + b.status, + ]); + + this.setHeaders(res, `bookings-${new Date().toISOString().split('T')[0]}.csv`); + res.send(toCsv(headers, rows)); + } + + async exportMembers(res: Response, from?: string, to?: string) { + const filter = dateFilter(from, to); + const users = await this.userRepo.find({ + where: filter ? { createdAt: filter } : {}, + order: { createdAt: 'DESC' }, + }); + + const headers = ['memberId', 'name', 'email', 'role', 'membershipStatus', 'joinedAt']; + const rows = users.map((u) => [ + u.id, + `${u.firstname} ${u.lastname}`, + u.email, + u.role, + u.membershipStatus, + u.createdAt.toISOString(), + ]); + + this.setHeaders(res, `members-${new Date().toISOString().split('T')[0]}.csv`); + res.send(toCsv(headers, rows)); + } + + async exportRevenue(res: Response, from?: string, to?: string) { + const filter = dateFilter(from, to); + const payments = await this.paymentRepo.find({ + where: filter ? { createdAt: filter } : {}, + order: { createdAt: 'ASC' }, + }); + + // Group by date + const byDate = new Map(); + for (const p of payments) { + const date = p.createdAt.toISOString().split('T')[0]; + const existing = byDate.get(date) ?? { total: 0, count: 0 }; + existing.total += Number(p.amount); + existing.count += 1; + byDate.set(date, existing); + } + + const headers = ['date', 'totalRevenue', 'bookingCount', 'averageBookingValue']; + const rows = [...byDate.entries()].map(([date, { total, count }]) => [ + date, + String(total), + String(count), + String(count > 0 ? Math.round(total / count) : 0), + ]); + + this.setHeaders(res, `revenue-${new Date().toISOString().split('T')[0]}.csv`); + res.send(toCsv(headers, rows)); + } + + async exportInvoices(res: Response, from?: string, to?: string) { + const filter = dateFilter(from, to); + const invoices = await this.invoiceRepo.find({ + where: filter ? { createdAt: filter } : {}, + relations: ['user'], + order: { createdAt: 'DESC' }, + }); + + const headers = ['invoiceNumber', 'member', 'amount', 'status', 'issuedAt', 'paidAt']; + const rows = invoices.map((inv) => [ + inv.invoiceNumber, + inv.user ? `${inv.user.firstname} ${inv.user.lastname}` : '', + String((inv as any).totalAmount ?? (inv as any).amount ?? 0), + inv.status, + inv.createdAt.toISOString(), + (inv as any).paidAt ? new Date((inv as any).paidAt).toISOString() : '', + ]); + + this.setHeaders(res, `invoices-${new Date().toISOString().split('T')[0]}.csv`); + res.send(toCsv(headers, rows)); + } +}