Commit a791a680 authored by Administrator's avatar Administrator

Update 7 files via Son of Anton

parent 1a902ad7
......@@ -55,6 +55,9 @@ import { MeetingsModule } from './modules/meetings/meetings.module';
// ─── Phase 2D: Reports & Daily Operations ───────────────────
import { ReportsModule } from './modules/reports/reports.module';
// ─── Phase 3A: Admin & Intelligence ─────────────────────────
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -108,6 +111,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
MeetingsModule,
// Phase 2D
ReportsModule,
// Phase 3A
AnalyticsModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Post,
Body,
Param,
Query,
Res,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { AnalyticsService } from './analytics.service';
import { ReportBuilderService } from './report-builder.service';
import { DataExportService } from './data-export.service';
import { AnalyticsFilterDto, ReportBuilderQueryDto, ExportRequestDto } from './dto/analytics-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
private readonly reportBuilderService: ReportBuilderService,
private readonly dataExportService: DataExportService,
) {}
// ─── DASHBOARDS ──────────────────────────────────────────
@Get('dashboard')
async getDashboard(@CurrentUser() user: RequestUser) {
switch (user.role) {
case 'SUPER_ADMIN':
return this.analyticsService.getSuperAdminDashboard();
case 'ADMIN':
return this.analyticsService.getAdminDashboard();
case 'TEAM_LEAD':
return this.analyticsService.getProjectLeaderDashboard(user.id);
case 'CONTRACTOR':
return this.analyticsService.getContractorDashboard(user.id);
default:
return {};
}
}
@Get('dashboard/contractor')
async getContractorDashboard(@CurrentUser() user: RequestUser) {
return this.analyticsService.getContractorDashboard(user.id);
}
@Get('dashboard/contractor/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getContractorDashboardAdmin(@Param('userId') userId: string) {
return this.analyticsService.getContractorDashboard(userId);
}
@Get('dashboard/project-leader')
@Roles('SUPER_ADMIN', 'TEAM_LEAD')
async getProjectLeaderDashboard(@CurrentUser() user: RequestUser) {
return this.analyticsService.getProjectLeaderDashboard(user.id);
}
@Get('dashboard/admin')
@Roles('SUPER_ADMIN', 'ADMIN')
async getAdminDashboard() {
return this.analyticsService.getAdminDashboard();
}
@Get('dashboard/super-admin')
@Roles('SUPER_ADMIN')
async getSuperAdminDashboard() {
return this.analyticsService.getSuperAdminDashboard();
}
// ─── ANALYTICS ENDPOINTS ─────────────────────────────────
@Get('deductions')
@Roles('SUPER_ADMIN', 'ADMIN')
async getDeductionAnalytics(@Query() filter: AnalyticsFilterDto) {
return this.analyticsService.getDeductionAnalytics(filter);
}
@Get('tasks')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async getTaskAnalytics(@Query() filter: AnalyticsFilterDto) {
return this.analyticsService.getTaskAnalytics(filter);
}
@Get('system-health')
@Roles('SUPER_ADMIN')
async getSystemHealth() {
return this.analyticsService.getSystemHealth();
}
// ─── CUSTOM REPORT BUILDER ───────────────────────────────
@Post('report-builder')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async executeReportQuery(@Body() query: ReportBuilderQueryDto, @CurrentUser() user: RequestUser) {
return this.reportBuilderService.executeQuery(query, user);
}
// ─── DATA EXPORT ─────────────────────────────────────────
@Post('export')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async exportData(
@Body() dto: ExportRequestDto,
@CurrentUser() user: RequestUser,
@Res() res: Response,
) {
const result = await this.dataExportService.exportData(dto, user);
const format = dto.format || 'CSV';
if (format === 'JSON') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=${result.filename}.json`);
res.send(JSON.stringify(result.data, null, 2));
} else {
// CSV
const csv = this.convertToCSV(result.data);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename=${result.filename}.csv`);
res.send(csv);
}
}
@Get('export/contractor/:userId')
@Roles('SUPER_ADMIN')
async exportContractorPackage(
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
@Res() res: Response,
) {
const data = await this.dataExportService.exportContractorPackage(userId, user);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=contractor-${userId}-${new Date().toISOString().split('T')[0]}.json`);
res.send(JSON.stringify(data, null, 2));
}
private convertToCSV(data: any[]): string {
if (!data || data.length === 0) return '';
const flattenObject = (obj: any, prefix = ''): Record<string, any> => {
const flat: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
Object.assign(flat, flattenObject(value, fullKey));
} else if (Array.isArray(value)) {
flat[fullKey] = value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : v)).join('; ');
} else {
flat[fullKey] = value;
}
}
return flat;
};
const flatData = data.map((row) => flattenObject(row));
const allKeys = new Set<string>();
flatData.forEach((row) => Object.keys(row).forEach((k) => allKeys.add(k)));
const headers = Array.from(allKeys);
const escapeCSV = (val: any): string => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const rows = [
headers.join(','),
...flatData.map((row) => headers.map((h) => escapeCSV(row[h])).join(',')),
];
return rows.join('\n');
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ReportBuilderService } from './report-builder.service';
import { DataExportService } from './data-export.service';
@Module({
controllers: [AnalyticsController],
providers: [AnalyticsService, ReportBuilderService, DataExportService],
exports: [AnalyticsService, ReportBuilderService, DataExportService],
})
export class AnalyticsModule {}
\ No newline at end of file
import { Injectable, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AnalyticsFilterDto } from './dto/analytics-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { piasterToEgp } from '../../common/utils/salary.util';
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(private readonly prisma: PrismaService) {}
// ─── CONTRACTOR DASHBOARD ─────────────────────────────────
async getContractorDashboard(userId: string): Promise<any> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
firstName: true,
lastName: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
weeklySchedule: true,
currentStreak: true,
bestStreak: true,
status: true,
},
});
if (!user) return null;
// Tasks assigned & in active columns
const myCards = await this.prisma.card.findMany({
where: {
assignees: { some: { id: userId } },
deletedAt: null,
isArchived: false,
},
include: {
column: { select: { id: true, name: true, type: true, board: { select: { id: true, name: true, key: true } } } },
labels: { select: { id: true, name: true, color: true } },
},
orderBy: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { position: 'asc' }],
});
// Group by column type priority
const columnPriority: Record<string, number> = {
DOING: 0, TODO: 1, IN_REVIEW: 2, FROZEN: 3, CUSTOM: 4, BACKLOG: 5, DONE: 6,
};
const sortedCards = myCards
.filter((c) => c.column.type !== 'DONE')
.sort((a, b) => (columnPriority[a.column.type] ?? 4) - (columnPriority[b.column.type] ?? 4));
// Upcoming deadlines (next 7 days)
const sevenDaysOut = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const upcomingDeadlines = myCards.filter(
(c) => c.dueDate && new Date(c.dueDate) >= now && new Date(c.dueDate) <= sevenDaysOut && !c.completedAt,
);
// This month stats
const { getScheduledDaysOfWeek, getWorkingDaysInMonth } = await import('../../common/utils/date.util');
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const expectedDays = getWorkingDaysInMonth(year, month, scheduledDays);
let daysReported = 0;
try {
const dailyReportModel = (this.prisma as any).dailyReport;
if (dailyReportModel && typeof dailyReportModel.count === 'function') {
daysReported = await dailyReportModel.count({
where: {
userId,
reportDate: { gte: monthStart, lte: monthEnd },
status: { not: 'DRAFT' },
},
});
}
} catch { /* Phase 2D may not be migrated yet */ }
// Learning goals
const learningGoals = await this.prisma.learningGoal.findMany({
where: {
userId,
status: { in: ['ACTIVE', 'OVERDUE', 'EXTENDED'] },
},
include: { competencyArea: { select: { name: true } } },
orderBy: { deadline: 'asc' },
take: 5,
});
// Upcoming meetings
const upcomingMeetings = await this.prisma.meeting.findMany({
where: {
startTime: { gte: now },
status: 'SCHEDULED',
invitees: { some: { userId } },
},
orderBy: { startTime: 'asc' },
take: 3,
});
// Unread notifications count
const unreadNotifications = await this.prisma.notification.count({
where: { userId, isRead: false },
});
return {
user: {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
status: user.status,
},
tasks: {
total: sortedCards.length,
doing: sortedCards.filter((c) => c.column.type === 'DOING').length,
todo: sortedCards.filter((c) => c.column.type === 'TODO').length,
inReview: sortedCards.filter((c) => c.column.type === 'IN_REVIEW').length,
frozen: sortedCards.filter((c) => c.column.type === 'FROZEN').length,
cards: sortedCards.slice(0, 20).map((c) => ({
id: c.id,
cardNumber: c.cardNumber,
title: c.title,
columnName: c.column.name,
columnType: c.column.type,
boardName: c.column.board.name,
boardKey: c.column.board.key,
dueDate: c.dueDate,
isOverdue: c.dueDate ? new Date(c.dueDate) < now : false,
priority: c.priority,
bountyPiasters: c.bountyPiasters,
labels: c.labels,
})),
},
upcomingDeadlines: upcomingDeadlines.map((c) => ({
id: c.id,
cardNumber: c.cardNumber,
title: c.title,
dueDate: c.dueDate,
boardName: c.column.board.name,
priority: c.priority,
})),
thisMonth: {
daysReported,
expectedDays,
month,
year,
},
streak: {
current: user.currentStreak || 0,
best: user.bestStreak || 0,
},
learningGoals: learningGoals.map((g) => ({
id: g.id,
title: g.title,
competencyArea: g.competencyArea?.name,
deadline: g.deadline,
status: g.status,
isOverdue: g.deadline < now && g.status === 'ACTIVE',
})),
upcomingMeetings: upcomingMeetings.map((m) => ({
id: m.id,
title: m.title,
startTime: m.startTime,
location: m.location,
})),
unreadNotifications,
};
}
// ─── PROJECT LEADER DASHBOARD ─────────────────────────────
async getProjectLeaderDashboard(userId: string): Promise<any> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1);
// Get boards the PL manages
const plBoards = await this.prisma.boardMember.findMany({
where: { userId },
select: { boardId: true, board: { select: { id: true, name: true, key: true } } },
});
const boardIds = plBoards.map((b) => b.boardId);
// Team members (contractors on PL's boards)
const teamMemberIds = new Set<string>();
for (const boardId of boardIds) {
const members = await this.prisma.boardMember.findMany({
where: { boardId },
include: { user: { select: { id: true, firstName: true, lastName: true, avatar: true, role: true, status: true, assignedProjectLeaderId: true } } },
});
for (const m of members) {
if (m.user.role === 'CONTRACTOR' && (m.user.status === 'ACTIVE' || m.user.status === 'ON_PIP')) {
teamMemberIds.add(m.userId);
}
}
}
const teamIds = Array.from(teamMemberIds);
// Team report status today
let reportStatus: any[] = [];
try {
const dailyReportModel = (this.prisma as any).dailyReport;
if (dailyReportModel && typeof dailyReportModel.findMany === 'function') {
const todaysReports = await dailyReportModel.findMany({
where: {
userId: { in: teamIds },
reportDate: { gte: today, lte: todayEnd },
status: { not: 'DRAFT' },
},
select: { userId: true, status: true },
});
const reportedUserIds = new Set(todaysReports.map((r: any) => r.userId));
const teamUsers = await this.prisma.user.findMany({
where: { id: { in: teamIds } },
select: { id: true, firstName: true, lastName: true, avatar: true },
});
reportStatus = teamUsers.map((u) => ({
...u,
reported: reportedUserIds.has(u.id),
reportStatus: todaysReports.find((r: any) => r.userId === u.id)?.status || null,
}));
}
} catch { /* Reports module may not exist yet */ }
// Pending report reviews
let pendingReviews = 0;
try {
const dailyReportModel = (this.prisma as any).dailyReport;
if (dailyReportModel && typeof dailyReportModel.count === 'function') {
pendingReviews = await dailyReportModel.count({
where: {
userId: { in: teamIds },
status: { in: ['SUBMITTED', 'LATE'] },
},
});
}
} catch { /* ok */ }
// Board overview - card counts by column
const boardOverview = [];
for (const b of plBoards) {
const columns = await this.prisma.column.findMany({
where: { boardId: b.boardId },
orderBy: { position: 'asc' },
select: {
id: true,
name: true,
type: true,
_count: { select: { cards: { where: { deletedAt: null, isArchived: false } } } },
},
});
boardOverview.push({
board: b.board,
columns: columns.map((c) => ({
name: c.name,
type: c.type,
cardCount: (c as any)._count?.cards || 0,
})),
});
}
// At-risk tasks (overdue or due within 2 days)
const twoDaysOut = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
const atRiskCards = await this.prisma.card.findMany({
where: {
column: { boardId: { in: boardIds } },
dueDate: { lte: twoDaysOut },
completedAt: null,
deletedAt: null,
isArchived: false,
},
include: {
assignees: { select: { id: true, firstName: true, lastName: true, avatar: true } },
column: { select: { name: true, board: { select: { key: true } } } },
},
orderBy: { dueDate: 'asc' },
take: 20,
});
// Deductions initiated by PL pending review
const pendingDeductions = await this.prisma.deduction.count({
where: {
initiatedById: userId,
status: 'PENDING_ADMIN_REVIEW',
},
});
// Evaluations due (if in evaluation period)
const pendingEvaluations = await this.prisma.evaluation.count({
where: {
user: { assignedProjectLeaderId: userId },
status: 'PENDING_TECHNICAL',
month,
year,
},
});
// Upcoming meetings
const upcomingMeetings = await this.prisma.meeting.findMany({
where: {
startTime: { gte: now },
status: 'SCHEDULED',
OR: [
{ createdById: userId },
{ invitees: { some: { userId } } },
],
},
orderBy: { startTime: 'asc' },
take: 3,
include: {
invitees: {
include: { user: { select: { id: true, firstName: true, lastName: true } } },
},
},
});
return {
teamReportStatus: reportStatus,
pendingReviews,
boardOverview,
atRiskTasks: atRiskCards.map((c) => ({
id: c.id,
cardNumber: c.cardNumber,
title: c.title,
dueDate: c.dueDate,
isOverdue: c.dueDate ? new Date(c.dueDate) < now : false,
columnName: c.column.name,
boardKey: c.column.board.key,
assignees: c.assignees,
})),
pendingDeductions,
pendingEvaluations,
upcomingMeetings: upcomingMeetings.map((m) => ({
id: m.id,
title: m.title,
startTime: m.startTime,
inviteeCount: m.invitees.length,
})),
teamSize: teamIds.length,
};
}
// ─── ADMIN DASHBOARD ──────────────────────────────────────
async getAdminDashboard(): Promise<any> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
// Active contractors by type and status
const contractorsByStatus = await this.prisma.user.groupBy({
by: ['status', 'contractorType'],
where: { role: 'CONTRACTOR', deletedAt: null },
_count: true,
});
const fullTimerCount = contractorsByStatus
.filter((c) => c.contractorType === 'FULL_TIME' && c.status !== 'OFFBOARDED')
.reduce((sum, c) => sum + c._count, 0);
const internCount = contractorsByStatus
.filter((c) => c.contractorType === 'INTERN' && c.status !== 'OFFBOARDED')
.reduce((sum, c) => sum + c._count, 0);
// Onboarding pipeline
const onboarding = await this.prisma.user.findMany({
where: { status: 'ONBOARDING', deletedAt: null },
select: { id: true, firstName: true, lastName: true, createdAt: true },
});
// Payroll status
const currentPayroll = await this.prisma.payroll.findUnique({
where: { month_year: { month, year } },
select: { id: true, status: true, totalNetPiasters: true, contractorCount: true },
});
// Deductions this month
const deductionsThisMonth = await this.prisma.deduction.aggregate({
where: {
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
_sum: { appliedAmountPiasters: true },
_count: true,
});
// Bounties this month
const bountiesThisMonth = await this.prisma.bountyPayout.aggregate({
where: {
payrollMonth: month,
payrollYear: year,
revokedAt: null,
},
_sum: { amountPiasters: true },
_count: true,
});
// Active PIPs
const activePips = await this.prisma.pip.findMany({
where: { status: 'ACTIVE' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
// Contract expirations
const thirtyDaysOut = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const sixtyDaysOut = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000);
const ninetyDaysOut = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
const expiringContracts = await this.prisma.contract.findMany({
where: {
endDate: { lte: ninetyDaysOut, gte: now },
status: 'ACTIVE',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { endDate: 'asc' },
});
// Report compliance
let reportCompliance = { submitted: 0, expected: 0, onTime: 0 };
try {
const dailyReportModel = (this.prisma as any).dailyReport;
if (dailyReportModel && typeof dailyReportModel.count === 'function') {
const submitted = await dailyReportModel.count({
where: {
reportDate: { gte: monthStart, lte: monthEnd },
status: { not: 'DRAFT' },
},
});
const onTime = await dailyReportModel.count({
where: {
reportDate: { gte: monthStart, lte: monthEnd },
status: { in: ['SUBMITTED', 'APPROVED', 'AUTO_APPROVED', 'AMENDED'] },
},
});
reportCompliance = { submitted, expected: 0, onTime };
}
} catch { /* ok */ }
// Pending actions
const pendingDeductionReviews = await this.prisma.deduction.count({
where: { status: 'PENDING_ADMIN_REVIEW' },
});
const pendingAdjustments = await this.prisma.adjustment.count({
where: { status: 'PENDING_APPROVAL' },
});
const pendingScheduleChanges = await this.prisma.scheduleChangeRequest.count({
where: { status: 'PENDING' },
});
// Unreported contractors in last 7 days
const unreportedDeductions = await this.prisma.deduction.findMany({
where: {
subCategory: 'B2',
createdAt: { gte: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) },
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
distinct: ['userId'],
});
return {
contractors: {
total: fullTimerCount + internCount,
fullTimers: fullTimerCount,
interns: internCount,
byStatus: contractorsByStatus.map((g) => ({
status: g.status,
type: g.contractorType,
count: g._count,
})),
},
onboarding: {
count: onboarding.length,
list: onboarding,
},
payroll: currentPayroll
? {
status: currentPayroll.status,
totalNetPiasters: currentPayroll.totalNetPiasters,
contractorCount: currentPayroll.contractorCount,
}
: { status: 'PENDING_CALCULATION', totalNetPiasters: 0, contractorCount: 0 },
deductionsThisMonth: {
count: deductionsThisMonth._count,
totalPiasters: deductionsThisMonth._sum.appliedAmountPiasters || 0,
},
bountiesThisMonth: {
count: bountiesThisMonth._count,
totalPiasters: bountiesThisMonth._sum.amountPiasters || 0,
},
activePips: activePips.map((p) => ({
id: p.id,
user: p.user,
startDate: p.startDate,
endDate: p.endDate,
status: p.status,
})),
expiringContracts: expiringContracts.map((c) => ({
id: c.id,
user: c.user,
endDate: c.endDate,
daysRemaining: Math.ceil((new Date(c.endDate!).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)),
})),
reportCompliance,
pendingActions: {
deductionReviews: pendingDeductionReviews,
adjustments: pendingAdjustments,
scheduleChanges: pendingScheduleChanges,
total: pendingDeductionReviews + pendingAdjustments + pendingScheduleChanges,
},
unreportedContractors: unreportedDeductions.map((d) => ({
userId: d.userId,
user: d.user,
})),
};
}
// ─── SUPER ADMIN DASHBOARD ────────────────────────────────
async getSuperAdminDashboard(): Promise<any> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const lastMonth = month === 1 ? 12 : month - 1;
const lastMonthYear = month === 1 ? year - 1 : year;
// Get admin dashboard as base
const adminData = await this.getAdminDashboard();
// Total monthly expense
const allActiveContractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
},
select: { id: true, actualSalaryPiasters: true, baseSalaryPiasters: true },
});
const totalSalaryPiasters = allActiveContractors.reduce(
(sum, c) => sum + (c.actualSalaryPiasters || c.baseSalaryPiasters || 0),
0,
);
const totalBountyPiasters = adminData.bountiesThisMonth.totalPiasters;
const totalDeductionPiasters = adminData.deductionsThisMonth.totalPiasters;
// Adjustments this month
const positiveAdjustments = await this.prisma.adjustment.aggregate({
where: {
effectiveMonth: month,
effectiveYear: year,
type: 'POSITIVE',
status: 'APPROVED',
},
_sum: { amountPiasters: true },
});
const negativeAdjustments = await this.prisma.adjustment.aggregate({
where: {
effectiveMonth: month,
effectiveYear: year,
type: 'NEGATIVE',
status: 'APPROVED',
},
_sum: { amountPiasters: true },
});
const totalExpense = totalSalaryPiasters + totalBountyPiasters +
(positiveAdjustments._sum.amountPiasters || 0) -
totalDeductionPiasters -
(negativeAdjustments._sum.amountPiasters || 0);
// Last month comparison
const lastMonthPayroll = await this.prisma.payroll.findUnique({
where: { month_year: { month: lastMonth, year: lastMonthYear } },
select: { totalNetPiasters: true },
});
// Average evaluation score (last 3 months)
const recentEvals = await this.prisma.evaluation.aggregate({
where: {
overallScore: { not: null },
year,
month: { gte: month - 3 },
},
_avg: { overallScore: true },
});
// Team growth: last 6 months
const sixMonthsAgo = new Date(year, month - 7, 1);
const hires = await this.prisma.user.groupBy({
by: ['createdAt'],
where: {
role: 'CONTRACTOR',
createdAt: { gte: sixMonthsAgo },
},
_count: true,
});
// Top performers (highest eval + most bounties)
const topByEval = await this.prisma.evaluation.findMany({
where: { month, year, overallScore: { not: null } },
orderBy: { overallScore: 'desc' },
take: 5,
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
const topByBounty = await this.prisma.bountyPayout.groupBy({
by: ['userId'],
where: { payrollMonth: month, payrollYear: year, revokedAt: null },
_sum: { amountPiasters: true },
orderBy: { _sum: { amountPiasters: 'desc' } },
take: 5,
});
const topBountyUsers = [];
for (const tb of topByBounty) {
const user = await this.prisma.user.findUnique({
where: { id: tb.userId },
select: { id: true, firstName: true, lastName: true, avatar: true },
});
topBountyUsers.push({ user, totalPiasters: tb._sum.amountPiasters });
}
// At-risk contractors (highest deductions, lowest evals)
const highDeductionUsers = await this.prisma.deduction.groupBy({
by: ['userId'],
where: {
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
_sum: { appliedAmountPiasters: true },
_count: true,
orderBy: { _sum: { appliedAmountPiasters: 'desc' } },
take: 5,
});
const atRiskContractors = [];
for (const hd of highDeductionUsers) {
const user = await this.prisma.user.findUnique({
where: { id: hd.userId },
select: { id: true, firstName: true, lastName: true, avatar: true, actualSalaryPiasters: true },
});
if (user) {
const salary = user.actualSalaryPiasters || 0;
const deductionPct = salary > 0 ? ((hd._sum.appliedAmountPiasters || 0) / salary) * 100 : 0;
atRiskContractors.push({
user,
totalDeductionPiasters: hd._sum.appliedAmountPiasters,
deductionCount: hd._count,
deductionPercentage: Math.round(deductionPct * 10) / 10,
});
}
}
// Active sessions
const activeSessions = await this.prisma.session.count({
where: { revokedAt: null, expiresAt: { gt: now } },
});
return {
...adminData,
financials: {
totalSalaryPiasters,
totalBountyPiasters,
totalDeductionPiasters,
totalPositiveAdjustmentPiasters: positiveAdjustments._sum.amountPiasters || 0,
totalNegativeAdjustmentPiasters: negativeAdjustments._sum.amountPiasters || 0,
netExpensePiasters: totalExpense,
lastMonthNetPiasters: lastMonthPayroll?.totalNetPiasters || 0,
monthOverMonthChange: lastMonthPayroll?.totalNetPiasters
? Math.round(((totalExpense - lastMonthPayroll.totalNetPiasters) / lastMonthPayroll.totalNetPiasters) * 1000) / 10
: 0,
},
performance: {
averageEvaluationScore: recentEvals._avg.overallScore
? Math.round(recentEvals._avg.overallScore * 100) / 100
: null,
topPerformersByEval: topByEval.map((e) => ({
user: e.user,
score: e.overallScore,
rating: e.rating,
})),
topPerformersByBounty: topBountyUsers,
atRiskContractors,
},
system: {
activeSessions,
activeContractors: allActiveContractors.length,
},
};
}
// ─── DEDUCTION ANALYTICS ──────────────────────────────────
async getDeductionAnalytics(filter: AnalyticsFilterDto): Promise<any> {
const month = filter.month || new Date().getMonth() + 1;
const year = filter.year || new Date().getFullYear();
// By category
const byCategory = await this.prisma.deduction.groupBy({
by: ['category'],
where: {
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
},
_sum: { appliedAmountPiasters: true },
_count: true,
});
// By sub-category
const bySubCategory = await this.prisma.deduction.groupBy({
by: ['subCategory'],
where: {
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
},
_sum: { appliedAmountPiasters: true },
_count: true,
orderBy: { _count: { subCategory: 'desc' } },
});
// Monthly trend (last 6 months)
const trends = [];
for (let i = 5; i >= 0; i--) {
let m = month - i;
let y = year;
if (m <= 0) { m += 12; y -= 1; }
const agg = await this.prisma.deduction.aggregate({
where: {
payrollMonth: m,
payrollYear: y,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
},
_sum: { appliedAmountPiasters: true },
_count: true,
});
trends.push({
month: m,
year: y,
count: agg._count,
totalPiasters: agg._sum.appliedAmountPiasters || 0,
});
}
return {
byCategory: byCategory.map((c) => ({
category: c.category,
count: c._count,
totalPiasters: c._sum.appliedAmountPiasters || 0,
})),
bySubCategory: bySubCategory.map((c) => ({
subCategory: c.subCategory,
count: c._count,
totalPiasters: c._sum.appliedAmountPiasters || 0,
})),
monthlyTrend: trends,
month,
year,
};
}
// ─── TASK ANALYTICS ───────────────────────────────────────
async getTaskAnalytics(filter: AnalyticsFilterDto): Promise<any> {
const month = filter.month || new Date().getMonth() + 1;
const year = filter.year || new Date().getFullYear();
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
const baseWhere: any = {
deletedAt: null,
};
if (filter.boardId) {
baseWhere.column = { boardId: filter.boardId };
}
// Cards completed this month
const completed = await this.prisma.card.count({
where: { ...baseWhere, completedAt: { gte: monthStart, lte: monthEnd } },
});
// Cards created this month
const created = await this.prisma.card.count({
where: { ...baseWhere, createdAt: { gte: monthStart, lte: monthEnd } },
});
// Cards currently overdue
const overdue = await this.prisma.card.count({
where: {
...baseWhere,
dueDate: { lt: new Date() },
completedAt: null,
isArchived: false,
},
});
// Average cycle time for completed cards this month
const completedCards = await this.prisma.card.findMany({
where: {
...baseWhere,
completedAt: { gte: monthStart, lte: monthEnd },
cycleTimeHours: { not: null },
},
select: { cycleTimeHours: true, leadTimeHours: true },
});
const avgCycleHours = completedCards.length > 0
? completedCards.reduce((sum, c) => sum + (c.cycleTimeHours || 0), 0) / completedCards.length
: 0;
const avgLeadHours = completedCards.length > 0
? completedCards.reduce((sum, c) => sum + (c.leadTimeHours || 0), 0) / completedCards.length
: 0;
// Completion by assignee
const completionByAssignee = await this.prisma.card.findMany({
where: {
...baseWhere,
completedAt: { gte: monthStart, lte: monthEnd },
},
select: {
assignees: { select: { id: true, firstName: true, lastName: true } },
},
});
const assigneeCounts: Record<string, { user: any; count: number }> = {};
for (const card of completionByAssignee) {
for (const a of card.assignees) {
if (!assigneeCounts[a.id]) {
assigneeCounts[a.id] = { user: a, count: 0 };
}
assigneeCounts[a.id].count++;
}
}
const topCompletors = Object.values(assigneeCounts)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
return {
month,
year,
completed,
created,
overdue,
avgCycleTimeHours: Math.round(avgCycleHours * 10) / 10,
avgLeadTimeHours: Math.round(avgLeadHours * 10) / 10,
topCompletors,
};
}
// ─── SYSTEM HEALTH ────────────────────────────────────────
async getSystemHealth(): Promise<any> {
const now = new Date();
const activeSessions = await this.prisma.session.count({
where: { revokedAt: null, expiresAt: { gt: now } },
});
const totalUsers = await this.prisma.user.groupBy({
by: ['status'],
where: { deletedAt: null },
_count: true,
});
// Recent errors in audit trail
const recentErrors = await this.prisma.auditTrail.count({
where: {
errorMessage: { not: null },
createdAt: { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) },
},
});
// Total audit trail entries
const totalAuditEntries = await this.prisma.auditTrail.count();
// Total attachments
const totalAttachments = await this.prisma.attachment.count();
const totalAttachmentSize = await this.prisma.attachment.aggregate({
_sum: { sizeBytes: true },
});
// Total cards
const totalCards = await this.prisma.card.count({ where: { deletedAt: null } });
const totalBoards = await this.prisma.board.count({ where: { deletedAt: null } });
// Database rough size estimation (count of major tables)
const tableCounts = {
users: await this.prisma.user.count(),
boards: totalBoards,
cards: totalCards,
comments: await this.prisma.comment.count(),
deductions: await this.prisma.deduction.count(),
notifications: await this.prisma.notification.count(),
messages: await this.prisma.message.count(),
auditTrail: totalAuditEntries,
attachments: totalAttachments,
};
return {
activeSessions,
usersByStatus: totalUsers.map((u) => ({ status: u.status, count: u._count })),
recentErrors24h: recentErrors,
storage: {
totalAttachments,
totalSizeBytes: totalAttachmentSize._sum.sizeBytes || 0,
totalSizeMB: Math.round((totalAttachmentSize._sum.sizeBytes || 0) / 1048576),
},
entityCounts: tableCounts,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
nodeVersion: process.version,
timestamp: now.toISOString(),
};
}
}
\ No newline at end of file
import {
Injectable,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ExportRequestDto } from './dto/analytics-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class DataExportService {
private readonly logger = new Logger(DataExportService.name);
constructor(private readonly prisma: PrismaService) {}
async exportData(dto: ExportRequestDto, currentUser: RequestUser): Promise<{ data: any[]; filename: string }> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
if (currentUser.role === 'TEAM_LEAD') {
const allowedEntities = ['CARDS'];
if (!allowedEntities.includes(dto.entityType)) {
throw new ForbiddenException('Project Leaders can only export card data for their boards');
}
} else {
throw new ForbiddenException('Insufficient permissions to export data');
}
}
const timestamp = new Date().toISOString().split('T')[0];
switch (dto.entityType) {
case 'CONTRACTORS':
return {
data: await this.exportContractors(dto),
filename: `contractors-${timestamp}`,
};
case 'CARDS':
return {
data: await this.exportCards(dto, currentUser),
filename: `cards-${timestamp}`,
};
case 'DEDUCTIONS':
return {
data: await this.exportDeductions(dto),
filename: `deductions-${timestamp}`,
};
case 'BOUNTIES':
return {
data: await this.exportBounties(dto),
filename: `bounties-${timestamp}`,
};
case 'EVALUATIONS':
return {
data: await this.exportEvaluations(dto),
filename: `evaluations-${timestamp}`,
};
case 'PAYROLL':
return {
data: await this.exportPayroll(dto),
filename: `payroll-${timestamp}`,
};
case 'ADJUSTMENTS':
return {
data: await this.exportAdjustments(dto),
filename: `adjustments-${timestamp}`,
};
case 'AUDIT_TRAIL':
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can export audit trail');
}
return {
data: await this.exportAuditTrail(dto),
filename: `audit-trail-${timestamp}`,
};
default:
throw new BadRequestException(`Unsupported entity type: ${dto.entityType}`);
}
}
async exportContractorPackage(userId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can export full contractor data packages');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
contracts: true,
sessions: { take: 50, orderBy: { createdAt: 'desc' } },
},
});
if (!user) throw new BadRequestException('Contractor not found');
const deductions = await this.prisma.deduction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const bounties = await this.prisma.bountyPayout.findMany({
where: { userId },
orderBy: { paidAt: 'desc' },
});
const evaluations = await this.prisma.evaluation.findMany({
where: { userId },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const pips = await this.prisma.pip.findMany({
where: { userId },
include: { checkIns: true },
});
const learningGoals = await this.prisma.learningGoal.findMany({
where: { userId },
});
const adjustments = await this.prisma.adjustment.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const unavailability = await this.prisma.unavailability.findMany({
where: { userId },
orderBy: { startDate: 'desc' },
});
const payrollLines = await this.prisma.payrollLine.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
// Strip password hash from user data
const { passwordHash, ...safeUser } = user;
return {
exportDate: new Date().toISOString(),
contractor: safeUser,
deductions,
bounties,
evaluations,
pips,
learningGoals,
adjustments,
unavailability,
payrollLines,
};
}
private async exportContractors(dto: ExportRequestDto): Promise<any[]> {
const where: any = { role: 'CONTRACTOR', deletedAt: null };
if (dto.userId) where.id = dto.userId;
return this.prisma.user.findMany({
where,
select: {
id: true,
firstName: true,
lastName: true,
username: true,
email: true,
contractorType: true,
status: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
currentStreak: true,
bestStreak: true,
createdAt: true,
activatedAt: true,
lastLoginAt: true,
},
orderBy: { createdAt: 'desc' },
take: 10000,
});
}
private async exportCards(dto: ExportRequestDto, currentUser: RequestUser): Promise<any[]> {
const where: any = { deletedAt: null };
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
if (currentUser.role === 'TEAM_LEAD') {
const plBoards = await this.prisma.boardMember.findMany({
where: { userId: currentUser.id },
select: { boardId: true },
});
where.column = { boardId: { in: plBoards.map((b) => b.boardId) } };
}
return this.prisma.card.findMany({
where,
select: {
id: true,
cardNumber: true,
title: true,
priority: true,
dueDate: true,
completedAt: true,
bountyPiasters: true,
estimatedHours: true,
actualHours: true,
leadTimeHours: true,
cycleTimeHours: true,
frozenTimeHours: true,
isArchived: true,
createdAt: true,
column: { select: { name: true, type: true, board: { select: { name: true, key: true } } } },
assignees: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportDeductions(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.violationDate = {};
if (dto.dateFrom) where.violationDate.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.violationDate.lte = new Date(dto.dateTo);
}
return this.prisma.deduction.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true } },
initiatedBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportBounties(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.paidAt = {};
if (dto.dateFrom) where.paidAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.paidAt.lte = new Date(dto.dateTo);
}
return this.prisma.bountyPayout.findMany({
where,
orderBy: { paidAt: 'desc' },
take: 50000,
});
}
private async exportEvaluations(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
return this.prisma.evaluation.findMany({
where,
include: { user: { select: { firstName: true, lastName: true } } },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
take: 50000,
});
}
private async exportPayroll(dto: ExportRequestDto): Promise<any[]> {
return this.prisma.payroll.findMany({
orderBy: [{ year: 'desc' }, { month: 'desc' }],
include: {
lines: {
include: {
user: { select: { firstName: true, lastName: true } },
},
},
},
take: 1000,
});
}
private async exportAdjustments(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
return this.prisma.adjustment.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true } },
createdBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportAuditTrail(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
return this.prisma.auditTrail.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true, username: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString, IsInt, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class AnalyticsFilterDto {
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
month?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
year?: number;
}
export class ReportBuilderQueryDto extends PaginationDto {
@IsString()
dataSource: string;
// CONTRACTORS, CARDS, DEDUCTIONS, BOUNTIES, EVALUATIONS, REPORTS, PAYROLL, ADJUSTMENTS, UNAVAILABILITY, LEARNING_GOALS
@IsOptional()
@IsArray()
@IsString({ each: true })
columns?: string[];
@IsOptional()
@IsString()
groupBy?: string;
@IsOptional()
@IsString()
aggregation?: string; // SUM, AVG, COUNT, MIN, MAX
@IsOptional()
@IsString()
aggregationField?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
category?: string;
}
export class ExportRequestDto {
@IsString()
entityType: string;
// CONTRACTORS, CARDS, DEDUCTIONS, BOUNTIES, EVALUATIONS, REPORTS, PAYROLL, ADJUSTMENTS, AUDIT_TRAIL, ALL
@IsOptional()
@IsString()
format?: string; // CSV, JSON (default: CSV)
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
}
\ No newline at end of file
import {
Injectable,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ReportBuilderQueryDto } from './dto/analytics-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class ReportBuilderService {
private readonly logger = new Logger(ReportBuilderService.name);
constructor(private readonly prisma: PrismaService) {}
async executeQuery(query: ReportBuilderQueryDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const validSources = [
'CONTRACTORS', 'CARDS', 'DEDUCTIONS', 'BOUNTIES',
'EVALUATIONS', 'REPORTS', 'PAYROLL', 'ADJUSTMENTS',
'UNAVAILABILITY', 'LEARNING_GOALS',
];
if (!validSources.includes(query.dataSource)) {
throw new BadRequestException(`Invalid data source. Must be one of: ${validSources.join(', ')}`);
}
// PLs can only query their own team's data
if (currentUser.role === 'TEAM_LEAD') {
const allowedSources = ['CARDS', 'EVALUATIONS', 'LEARNING_GOALS'];
if (!allowedSources.includes(query.dataSource)) {
throw new ForbiddenException('Project Leaders can only query cards, evaluations, and learning goals for their team');
}
}
const page = query.page || 1;
const limit = query.limit || 50;
switch (query.dataSource) {
case 'CONTRACTORS':
return this.queryContractors(query, page, limit);
case 'CARDS':
return this.queryCards(query, page, limit, currentUser);
case 'DEDUCTIONS':
return this.queryDeductions(query, page, limit);
case 'BOUNTIES':
return this.queryBounties(query, page, limit);
case 'EVALUATIONS':
return this.queryEvaluations(query, page, limit, currentUser);
case 'PAYROLL':
return this.queryPayroll(query, page, limit);
case 'ADJUSTMENTS':
return this.queryAdjustments(query, page, limit);
case 'UNAVAILABILITY':
return this.queryUnavailability(query, page, limit);
case 'LEARNING_GOALS':
return this.queryLearningGoals(query, page, limit, currentUser);
default:
throw new BadRequestException('Unsupported data source');
}
}
private async queryContractors(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = { role: 'CONTRACTOR', deletedAt: null };
if (query.status) where.status = query.status;
const [data, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
firstName: true,
lastName: true,
username: true,
contractorType: true,
status: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
currentStreak: true,
bestStreak: true,
createdAt: true,
lastLoginAt: true,
},
}),
this.prisma.user.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryCards(query: ReportBuilderQueryDto, page: number, limit: number, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const where: any = { deletedAt: null };
if (query.dateFrom || query.dateTo) {
where.createdAt = {};
if (query.dateFrom) where.createdAt.gte = new Date(query.dateFrom);
if (query.dateTo) where.createdAt.lte = new Date(query.dateTo);
}
if (query.boardId) {
where.column = { boardId: query.boardId };
}
if (query.userId) {
where.assignees = { some: { id: query.userId } };
}
if (query.status) {
where.column = { ...where.column, type: query.status };
}
// PL restriction
if (currentUser.role === 'TEAM_LEAD') {
const plBoards = await this.prisma.boardMember.findMany({
where: { userId: currentUser.id },
select: { boardId: true },
});
where.column = { ...where.column, boardId: { in: plBoards.map((b) => b.boardId) } };
}
const [data, total] = await Promise.all([
this.prisma.card.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
cardNumber: true,
title: true,
priority: true,
dueDate: true,
completedAt: true,
bountyPiasters: true,
estimatedHours: true,
actualHours: true,
leadTimeHours: true,
cycleTimeHours: true,
frozenTimeHours: true,
createdAt: true,
column: { select: { name: true, type: true, board: { select: { name: true, key: true } } } },
assignees: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.card.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryDeductions(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.dateFrom || query.dateTo) {
where.violationDate = {};
if (query.dateFrom) where.violationDate.gte = new Date(query.dateFrom);
if (query.dateTo) where.violationDate.lte = new Date(query.dateTo);
}
if (query.userId) where.userId = query.userId;
if (query.status) where.status = query.status;
if (query.category) where.category = query.category;
const [data, total] = await Promise.all([
this.prisma.deduction.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.deduction.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryBounties(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = { revokedAt: null };
if (query.dateFrom || query.dateTo) {
where.paidAt = {};
if (query.dateFrom) where.paidAt.gte = new Date(query.dateFrom);
if (query.dateTo) where.paidAt.lte = new Date(query.dateTo);
}
if (query.userId) where.userId = query.userId;
const [data, total] = await Promise.all([
this.prisma.bountyPayout.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { paidAt: 'desc' },
}),
this.prisma.bountyPayout.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryEvaluations(query: ReportBuilderQueryDto, page: number, limit: number, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.userId) where.userId = query.userId;
if (query.status) where.status = query.status;
if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
const [data, total] = await Promise.all([
this.prisma.evaluation.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: [{ year: 'desc' }, { month: 'desc' }],
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.evaluation.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryPayroll(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.status) where.status = query.status;
const [data, total] = await Promise.all([
this.prisma.payroll.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: [{ year: 'desc' }, { month: 'desc' }],
}),
this.prisma.payroll.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryAdjustments(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.userId) where.userId = query.userId;
if (query.status) where.status = query.status;
if (query.category) where.category = query.category;
if (query.dateFrom || query.dateTo) {
where.createdAt = {};
if (query.dateFrom) where.createdAt.gte = new Date(query.dateFrom);
if (query.dateTo) where.createdAt.lte = new Date(query.dateTo);
}
const [data, total] = await Promise.all([
this.prisma.adjustment.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.adjustment.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryUnavailability(query: ReportBuilderQueryDto, page: number, limit: number): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.userId) where.userId = query.userId;
if (query.dateFrom || query.dateTo) {
if (query.dateFrom) where.startDate = { ...(where.startDate || {}), gte: new Date(query.dateFrom) };
if (query.dateTo) where.endDate = { ...(where.endDate || {}), lte: new Date(query.dateTo) };
}
const [data, total] = await Promise.all([
this.prisma.unavailability.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { startDate: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.unavailability.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
private async queryLearningGoals(query: ReportBuilderQueryDto, page: number, limit: number, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const where: any = {};
if (query.userId) where.userId = query.userId;
if (query.status) where.status = query.status;
if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
const [data, total] = await Promise.all([
this.prisma.learningGoal.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { deadline: 'asc' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
competencyArea: { select: { id: true, name: true } },
},
}),
this.prisma.learningGoal.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'asc' });
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment