diff --git a/Dockerfile b/Dockerfile index 080b450..c6a68b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,47 @@ -# Use the official Node.js image as the base image -FROM node:22 - -# Set the working directory inside the container -WORKDIR /app - -# Set the environment variables -ENV DATABASE_URL_PROD="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db?schema=public" -ENV DATABASE_URL_STAGING="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_staging?schema=public" -ENV DATABASE_URL_DEV="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_dev?schema=public" - -ENV AUTHENTIK_ISSUER="https://auth.targo.ca/application/o/montargo/" -ENV AUTHENTIK_CLIENT_ID="KUmSmvpu2aDDy4JfNwas7XriNFtPcj2Ka2PyLO5v" -ENV AUTHENTIK_CLIENT_SECRET="N55BgX1mxT7eiY99LOo5zXr5cKz9FgTsaCA9MdC7D8ZuhOGqozvqtNXVGbpY1eCg2kkYwJeJLP89sQ8R4cYybIJI7EwKijb19bzZQpUPwBosWwG3irUwdTnZOyw8yW5i" -ENV AUTHENTIK_CALLBACK_URL="http://10.100.251.2:3420/auth/callback" -ENV AUTHENTIK_AUTH_URL="https://auth.targo.ca/application/o/authorize/" -ENV AUTHENTIK_TOKEN_URL="https://auth.targo.ca/application/o/token/" -ENV AUTHENTIK_USERINFO_URL="https://auth.targo.ca/application/o/userinfo/" - -ENV TARGO_FRONTEND_URI="http://10.100.251.2/" - -ENV ATTACHMENTS_SERVER_ID="server" -ENV ATTACHMENTS_ROOT=C:/ -ENV MAX_UPLOAD_MB=25 -ENV ALLOWED_MIME=image/jpeg,image/png,image/webp,application/pdf - -# Copy package.json and package-lock.json to the working directory -COPY package*.json ./ - -# Install the application dependencies -RUN npm install - -# Copy the rest of the application files -COPY . . - -# Generate Prisma client -RUN npx prisma generate - -# Build the NestJS application -RUN npm run build - -# Expose the application port -EXPOSE 3000 - -# Command to run the application -CMD ["node", "dist/src/main"] +# Use the official Node.js image as the base image +FROM node:22 + +# Set the working directory inside the container +WORKDIR /app + +#Commentaire de fred pour faire un test +# Set the environment variables +ENV DATABASE_URL_PROD="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db?schema=public" +ENV DATABASE_URL_STAGING="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_staging?schema=public" +ENV DATABASE_URL_DEV="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_dev?schema=public" + +ENV AUTHENTIK_ISSUER="https://auth.targo.ca/application/o/montargo/" +ENV AUTHENTIK_CLIENT_ID="KUmSmvpu2aDDy4JfNwas7XriNFtPcj2Ka2PyLO5v" +ENV AUTHENTIK_CLIENT_SECRET="N55BgX1mxT7eiY99LOo5zXr5cKz9FgTsaCA9MdC7D8ZuhOGqozvqtNXVGbpY1eCg2kkYwJeJLP89sQ8R4cYybIJI7EwKijb19bzZQpUPwBosWwG3irUwdTnZOyw8yW5i" +ENV AUTHENTIK_CALLBACK_URL="http://10.100.251.2:3420/auth/callback" +ENV AUTHENTIK_AUTH_URL="https://auth.targo.ca/application/o/authorize/" +ENV AUTHENTIK_TOKEN_URL="https://auth.targo.ca/application/o/token/" +ENV AUTHENTIK_USERINFO_URL="https://auth.targo.ca/application/o/userinfo/" + +ENV TARGO_FRONTEND_URI="http://10.100.251.2/" + +ENV ATTACHMENTS_SERVER_ID="server" +ENV ATTACHMENTS_ROOT=C:/ +ENV MAX_UPLOAD_MB=25 +ENV ALLOWED_MIME=image/jpeg,image/png,image/webp,application/pdf + +# Copy package.json and package-lock.json to the working directory +COPY package*.json ./ + +# Install the application dependencies +RUN npm install + +# Copy the rest of the application files +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the NestJS application +RUN npm run build + +# Expose the application port +EXPOSE 3000 + +# Command to run the application +CMD ["node", "dist/src/main"] diff --git a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts index c955663..34a1a51 100644 --- a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts +++ b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts @@ -10,7 +10,13 @@ export class PayPeriodOverviewDto { export class EmployeePeriodOverviewDto { email: string; - employee_name: string; + employee_first_name: string; + employee_last_name: string; + supervisor?: { + first_name: string; + last_name: string; + email: string; + } | null; is_active: boolean; regular_hours: number; other_hours: { @@ -25,7 +31,6 @@ export class EmployeePeriodOverviewDto { expenses: number; mileage: number; is_approved: boolean; - is_remote: boolean; } export class PayPeriodBundleDto { @@ -40,4 +45,19 @@ export class PayPeriodDto { payday: string; pay_year: number; label: string; +} + +export type Overview = { + period_start: string; + period_end: string; + payday: string; + period_no: number; + pay_year: number; + label: string; + options?: options; +} + +export type options = { + filtered_employee_ids?: number[]; + seed_names?: Map } \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/pay-periods.controller.ts b/src/time-and-attendance/pay-period/pay-periods.controller.ts index dfcde18..41f7ddf 100644 --- a/src/time-and-attendance/pay-period/pay-periods.controller.ts +++ b/src/time-and-attendance/pay-period/pay-periods.controller.ts @@ -1,33 +1,21 @@ -import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, UnauthorizedException } from "@nestjs/common"; -import { PayPeriodBundleDto, PayPeriodOverviewDto } from "./dtos/overview-pay-period.dto"; +import { Body, Controller, Get, Param, ParseIntPipe, Patch } from "@nestjs/common"; +import { PayPeriodOverviewDto } from "./dtos/overview-pay-period.dto"; import { PayPeriodsQueryService } from "./services/pay-periods-query.service"; import { PayPeriodsCommandService } from "./services/pay-periods-command.service"; import { Result } from "src/common/errors/result-error.factory"; import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; import { Modules as ModulesEnum } from ".prisma/client"; -import { Access } from "src/common/decorators/module-access.decorators"; -import { BulkCrewApprovalDto } from "src/time-and-attendance/pay-period/dtos/bulk-crew-approval.dto"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; @Controller('pay-periods') export class PayPeriodsController { constructor( private readonly queryService: PayPeriodsQueryService, + private readonly getOverviewService: GetOverviewService, private readonly commandService: PayPeriodsCommandService, ) { } - @Get('current-and-all') - @ModuleAccessAllowed(ModulesEnum.timesheets) - async getCurrentAndAll(@Query('date') date?: string): Promise> { - const current = await this.queryService.findCurrent(date); - if (!current.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; - - const periods = await this.queryService.findAll(); - if (!periods.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; - - return { success: true, data: { current: current.data, periods: periods.data } }; - } - @Get("date/:date") @ModuleAccessAllowed(ModulesEnum.timesheets) async findByDate(@Param("date") date: string) { @@ -56,22 +44,12 @@ export class PayPeriodsController { return this.commandService.bulkApproveEmployee(email, timesheet_ids, is_approved); } - @Get('crew/:year/:periodNumber') - @ModuleAccessAllowed(ModulesEnum.timesheets_approval) - async getCrewOverview(@Access('email') email: string, - @Param('year', ParseIntPipe) year: number, - @Param('periodNumber', ParseIntPipe) period_no: number, - @Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false, - ): Promise> { - return this.queryService.getCrewOverview(year, period_no, email, include_subtree); - } - @Get('overview/:year/:periodNumber') @ModuleAccessAllowed(ModulesEnum.timesheets_approval) async getOverviewByYear( @Param('year', ParseIntPipe) year: number, @Param('periodNumber', ParseIntPipe) period_no: number, ): Promise> { - return this.queryService.getOverviewByYearPeriod(year, period_no); + return this.getOverviewService.getOverviewByYearPeriod(year, period_no); } } diff --git a/src/time-and-attendance/pay-period/pay-periods.module.ts b/src/time-and-attendance/pay-period/pay-periods.module.ts index 8781b70..3e375db 100644 --- a/src/time-and-attendance/pay-period/pay-periods.module.ts +++ b/src/time-and-attendance/pay-period/pay-periods.module.ts @@ -4,13 +4,15 @@ import { Module } from "@nestjs/common"; import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/services/pay-periods-command.service"; import { TimesheetsModule } from "src/time-and-attendance/timesheets/timesheets.module"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; @Module({ imports:[TimesheetsModule], controllers: [PayPeriodsController], providers: [ - PayPeriodsQueryService, + PayPeriodsQueryService, PayPeriodsCommandService, + GetOverviewService, EmailToIdResolver, ], }) diff --git a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts new file mode 100644 index 0000000..fca6fad --- /dev/null +++ b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts @@ -0,0 +1,222 @@ +import { Injectable } from "@nestjs/common"; +import { Result } from "src/common/errors/result-error.factory"; +import { computeHours, computePeriod, toDateFromString } from "src/common/utils/date-utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { EmployeePeriodOverviewDto, Overview, PayPeriodOverviewDto } from "src/time-and-attendance/pay-period/dtos/overview-pay-period.dto"; + + +@Injectable() +export class GetOverviewService { + constructor( + private readonly prisma: PrismaService, + ) { } + + async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise> { + const period = computePeriod(pay_year, period_no); + const overview = await this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + label: period.label, + + }); + if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + return { success: true, data: overview.data } + } + + async buildOverview(overview: Overview): Promise> { + const employee_overviews = await this.prisma.employees.findMany({ + where: { + OR: [ + { last_work_day: { gte: toDateFromString(overview.period_start) } }, + { last_work_day: null }, + ] + }, + select: { + id: true, + last_work_day: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + }, + }, + supervisor: { + select: { + user: { + select: { + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + timesheet: { + where: { + start_date: { gte: toDateFromString(overview.period_start), lte: toDateFromString(overview.period_end) }, + }, + select: { + id: true, + is_approved: true, + shift: { + select: { + start_time: true, + end_time: true, + bank_code: { select: { type: true } }, + }, + }, + expense: { + select: { + amount: true, + bank_code: { select: { type: true, modifier: true } }, + }, + }, + }, + }, + }, + orderBy: { user: { first_name: 'asc' } }, + }); + + const by_employee = new Map(); + + // seed for employee without data + if (overview.options?.seed_names) { + for (const [id, { first_name, last_name, email }] of overview.options.seed_names.entries()) { + by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name)); + } + } else { + for (const employee of employee_overviews) { + const record = this.createEmployeeSeeds( + employee.user.email, + employee.user.first_name, + employee.user.last_name, + employee.supervisor?.user ?? null, + ); + by_employee.set(employee.id, record); + } + } + + const ensure = (id: number, first_name: string, last_name: string, email: string) => { + if (!by_employee.has(id)) { + by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name)); + } + return by_employee.get(id)!; + }; + + for (const employee of employee_overviews) { + const record = ensure( + employee.id, + employee.user.first_name, + employee.user.last_name, + employee.user.email + ); + + for (const timesheet of employee.timesheet) { + //totals by types for shifts + for (const shift of timesheet.shift) { + const hours = computeHours(shift.start_time, shift.end_time); + const type = (shift.bank_code?.type ?? '').toUpperCase(); + switch (type) { + case "EVENING": record.other_hours.evening_hours += hours; + record.total_hours += hours; + break; + case "EMERGENCY": record.other_hours.emergency_hours += hours; + record.total_hours += hours; + break; + case "OVERTIME": record.other_hours.overtime_hours += hours; + record.total_hours += hours; + break; + case "SICK": record.other_hours.sick_hours += hours; + record.total_hours += hours; + break; + case "HOLIDAY": record.other_hours.holiday_hours += hours; + record.total_hours += hours; + break; + case "VACATION": record.other_hours.vacation_hours += hours; + record.total_hours += hours; + break; + case "REGULAR": record.regular_hours += hours; + record.total_hours += hours; + break; + } + } + //totals by type for expenses + for (const expense of timesheet.expense) { + const amount = Number(expense.amount) + record.expenses = Number((record.expenses + amount).toFixed(2)); + const type = (expense.bank_code?.type || "").toUpperCase(); + const rate = expense.bank_code?.modifier ?? 1; + const mileage = amount / rate; + if (type === "MILEAGE" && rate > 0) { + record.mileage = Number((record.mileage += Math.round(mileage)).toFixed(2)); + } + } + } + } + + for (const employee of employee_overviews) { + const record = by_employee.get(employee.id); + if (!record) continue; + const timesheets = employee.timesheet; + const has_data = timesheets.some(timesheet => timesheet.shift.length > 0 || timesheet.expense.length > 0); + + const cutoff_date = new Date(); + cutoff_date.setDate(cutoff_date.getDate() + 14); + const is_active = employee.last_work_day ? employee.last_work_day.getTime() >= cutoff_date.getTime() : true; + + record.is_approved = has_data && timesheets.every(timesheet => timesheet.is_approved === true); + record.is_active = is_active; + } + + const employees_overview = Array.from(by_employee.values()).sort((a, b) => + a.employee_first_name.localeCompare(b.employee_first_name, "fr", { sensitivity: "base" }), + ); + + return { + success: true, + data: { + pay_period_no: overview.period_no, + pay_year: overview.pay_year, + payday: overview.payday, + period_start: (overview.period_start), + period_end: overview.period_end, + label: overview.label, + employees_overview, + } + }; + } + + createEmployeeSeeds = ( + email: string, + employee_first_name: string, + employee_last_name: string, + supervisor: { + first_name: string; + last_name:string; + email: string; + } | null = null, + ): EmployeePeriodOverviewDto => ({ + email, + employee_first_name, + employee_last_name, + supervisor: supervisor ?? null, + is_active: true, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: false, + }); +} \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts index 5799f3c..b15d89e 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts @@ -1,7 +1,5 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { PayPeriodsQueryService } from "./pay-periods-query.service"; -import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service"; import { Result } from "src/common/errors/result-error.factory"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { Prisma } from "@prisma/client"; @@ -12,8 +10,6 @@ import { Prisma } from "@prisma/client"; export class PayPeriodsCommandService { constructor( private readonly prisma: PrismaService, - private readonly timesheetsApproval: TimesheetApprovalService, - private readonly query: PayPeriodsQueryService, private readonly emailResolver: EmailToIdResolver, ) { } diff --git a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts index 59b399c..90bd0a9 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts @@ -1,415 +1,14 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { computeHours, computePeriod, listPayYear, payYearOfDate, toStringFromDate } from "src/common/utils/date-utils"; -import { EmployeePeriodOverviewDto, PayPeriodDto, PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; +import { computePeriod, listPayYear, payYearOfDate } from "src/common/utils/date-utils"; +import { PayPeriodDto } from "../dtos/overview-pay-period.dto"; import { Result } from "src/common/errors/result-error.factory"; import { mapPayPeriodToDto } from "src/time-and-attendance/pay-period/pay-periods.mapper"; @Injectable() export class PayPeriodsQueryService { - constructor(private readonly prisma: PrismaService) { } - - async getOverview(pay_period_no: number): Promise> { - const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!period) return { success: false, error: `PAY_PERIOD_NOT_FOUND` }; - - const overview = await this.buildOverview({ - period_start: period.period_start, - period_end: period.period_end, - payday: period.payday, - period_no: period.pay_period_no, - pay_year: period.pay_year, - label: period.label, - }); - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - - return { success: true, data: overview.data } - } - - async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise> { - const period = computePeriod(pay_year, period_no); - const overview = await this.buildOverview({ - period_start: period.period_start, - period_end: period.period_end, - period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - label: period.label, - }); - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - return { success: true, data: overview.data } - } - - //find crew member associated with supervisor - private async resolveCrew(supervisor_id: number, include_subtree: boolean): - Promise, string>> { - const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - - let frontier = await this.prisma.employees.findMany({ - where: { supervisor_id: supervisor_id }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - result.push(...frontier.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - - if (!include_subtree) return { success: true, data: result }; - - while (frontier.length) { - const parent_ids = frontier.map(emp => emp.id); - const next = await this.prisma.employees.findMany({ - where: { supervisor_id: { in: parent_ids } }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - if (next.length === 0) break; - result.push(...next.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - frontier = next; - } - return { success: true, data: result }; - } - - //fetchs crew emails - async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise, string>> { - const crew = await this.resolveCrew(supervisor_id, include_subtree); - if (!crew.success) return { success: false, error: crew.error } - return { success: true, data: new Set(crew.data.map(crew_member => crew_member.email).filter(Boolean)) } - } - - async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): - Promise> { - // 1) Search for the period - const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); - if (!period) return { success: false, error: 'PAY_PERIOD_NOT_FOUND' } - - // 2) fetch supervisor - const supervisor = await this.prisma.employees.findFirst({ - where: { user: { email: email } }, - select: { - id: true, - is_supervisor: true, - }, - }); - - if (!supervisor) return { success: false, error: 'EMPLOYEE_NOT_FOUND' } - if (!supervisor.is_supervisor) return { success: false, error: 'INVALID_EMPLOYEE' } - - // 3)fetchs crew members - const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] - if (!crew.success) return { success: false, error: crew.error } - const crew_ids = crew.data.map(c => c.id); - // seed names map for employee without data - const seed_names = new Map( - crew.data.map(crew => [ - crew.id, - { - name: `${crew.first_name} ${crew.last_name}`.trim(), - email: crew.email - } - ] - ) - ); - const overview = await this.buildOverview({ - period_no: period.pay_period_no, - period_start: period.period_start, - period_end: period.period_end, - payday: period.payday, - pay_year: period.pay_year, - label: period.label, - }, { filtered_employee_ids: crew_ids, seed_names }) - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - - // 4) overview build - return { success: true, data: overview.data } - } - - private async buildOverview( - period: { - period_start: string | Date; period_end: string | Date; payday: string | Date; - period_no: number; pay_year: number; label: string; - }, //add is_approved - options?: { filtered_employee_ids?: number[]; seed_names?: Map } - ): Promise> { - const toDateString = (d: Date) => d.toISOString().slice(0, 10); - const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); - - const start = period.period_start instanceof Date - ? period.period_start - : new Date(`${period.period_start}T00:00:00.000Z`); - - const end = period.period_end instanceof Date - ? period.period_end - : new Date(`${period.period_end}T00:00:00.000Z`); - - const payd = period.payday instanceof Date - ? period.payday - : new Date(`${period.payday}T00:00:00.000Z`); - - //restrictEmployeeIds = filter for shifts and expenses by employees - const where_employee = options?.filtered_employee_ids?.length ? - { - date: { gte: start, lte: end }, - timesheet: { employee_id: { in: options.filtered_employee_ids } }, - } - : - { date: { gte: start, lte: end } }; - - // SHIFTS (filtered by crew) - const shifts = await this.prisma.shifts.findMany({ - where: where_employee, - select: { - start_time: true, - end_time: true, - is_remote: true, - timesheet: { - select: { - is_approved: true, - employee: { - select: { - id: true, - user: { - select: { - first_name: true, - last_name: true, - email: true, - } - }, - } - }, - }, - }, - bank_code: { select: { categorie: true, type: true } }, - }, - }); - - // EXPENSES (filtered by crew) - const expenses = await this.prisma.expenses.findMany({ - where: where_employee, - select: { - amount: true, - timesheet: { - select: { - is_approved: true, - employee: { - select: { - id: true, - user: { - select: { - first_name: true, - last_name: true, - email: true, - } - }, - } - }, - } - }, - bank_code: { select: { categorie: true, modifier: true, type: true } }, - }, - }); - - const by_employee = new Map(); - - // seed for employee without data - if (options?.seed_names) { - for (const [id, { name, email }] of options.seed_names.entries()) { - by_employee.set(id, { - email, - employee_name: name, - is_active: true, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: false, - is_remote: false, - }); - } - } else { - const all_employees = await this.prisma.employees.findMany({ - include: { - user: { - select: { - first_name: true, - last_name: true, - email: true - } - } - }, - }); - - for (const employee of all_employees) { - let is_active = true; - - if (employee.last_work_day !== null) { - is_active = this.checkForInactiveDate(employee.last_work_day) - } - - by_employee.set(employee.id, { - email: employee.user.email, - employee_name: employee.user.first_name + ' ' + employee.user.last_name, - is_active: is_active, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: false, - is_remote: false, - }); - } - } - - const ensure = (id: number, name: string, email: string) => { - if (!by_employee.has(id)) { - by_employee.set(id, { - email, - employee_name: name, - is_active: true, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: false, - is_remote: false, - }); - } - return by_employee.get(id)!; - }; - - for (const shift of shifts) { - const employee = shift.timesheet.employee; - const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); - const record = ensure(employee.id, name, employee.user.email); - - const hours = computeHours(shift.start_time, shift.end_time); - const type = (shift.bank_code?.type ?? '').toUpperCase(); - switch (type) { - case "EVENING": record.other_hours.evening_hours += hours; - record.total_hours += hours; - break; - case "EMERGENCY": record.other_hours.emergency_hours += hours; - record.total_hours += hours; - break; - case "OVERTIME": record.other_hours.overtime_hours += hours; - record.total_hours += hours; - break; - case "SICK": record.other_hours.sick_hours += hours; - record.total_hours += hours; - break; - case "HOLIDAY": record.other_hours.holiday_hours += hours; - record.total_hours += hours; - break; - case "VACATION": record.other_hours.vacation_hours += hours; - record.total_hours += hours; - break; - case "REGULAR": record.regular_hours = record.regular_hours += hours; - record.total_hours += hours; - break; - } - - record.is_approved = record.is_approved && shift.timesheet.is_approved; - record.is_remote = record.is_remote || !!shift.is_remote; - } - - for (const expense of expenses) { - const exp = expense.timesheet.employee; - const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); - const record = ensure(exp.id, name, exp.user.email); - - const amount = toMoney(expense.amount); - record.expenses = Number((record.expenses += amount).toFixed(2)); - - const type = (expense.bank_code?.type || "").toUpperCase(); - const rate = expense.bank_code?.modifier ?? 0; - if (type === "MILEAGE" && rate > 0) { - record.mileage = Number((record.mileage += Math.round((amount / rate) * 100) / 100).toFixed(2)); - } - record.is_approved = record.is_approved && expense.timesheet.is_approved; - } - - const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), - ); - - return { - success: true, - data: { - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: toDateString(payd), - period_start: toDateString(start), - period_end: toDateString(end), - label: period.label, - employees_overview, - } - }; - } - - async getSupervisor(email: string) { - return this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true, is_supervisor: true }, - }); - } - - async findAll(): Promise> { - const currentPayYear = payYearOfDate(new Date()); - return { - success: true, - data: listPayYear(currentPayYear).map(period => ({ - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - period_start: period.period_start, - period_end: period.period_end, - label: period.label, - })) - }; - } - - async findOne(period_no: number): Promise> { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!row) return { success: false, error: `PAY_PERIOD_NOT_FOUND` } - return { success: true, data: mapPayPeriodToDto(row) }; - } - - async findCurrent(date?: string): Promise> { - const iso_day = date ?? new Date().toISOString().slice(0, 10); - const pay_period = await this.findByDate(iso_day); - if (!pay_period.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - return { success: true, data: pay_period.data } - } + constructor( + private readonly prisma: PrismaService) { } async findOneByYearPeriod(pay_year: number, period_no: number): Promise> { const row = await this.prisma.payPeriods.findFirst({ @@ -458,20 +57,4 @@ export class PayPeriodsQueryService { } } } - - async getPeriodWindow(pay_year: number, period_no: number) { - return this.prisma.payPeriods.findFirst({ - where: { pay_year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, - }); - } - - private checkForInactiveDate = (last_work_day: Date) => { - const limit = new Date(last_work_day); - limit.setDate(limit.getDate() + 14); - if (limit >= new Date()) { - return true; - } - return false; - } } diff --git a/src/time-and-attendance/time-and-attendance.module.ts b/src/time-and-attendance/time-and-attendance.module.ts index bdc20fd..767e06f 100644 --- a/src/time-and-attendance/time-and-attendance.module.ts +++ b/src/time-and-attendance/time-and-attendance.module.ts @@ -17,6 +17,7 @@ import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper"; import { PayperiodsModule } from "src/time-and-attendance/pay-period/pay-periods.module"; import { PayPeriodsController } from "src/time-and-attendance/pay-period/pay-periods.controller"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; import { PayPeriodsQueryService } from "src/time-and-attendance/pay-period/services/pay-periods-query.service"; import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/services/pay-periods-command.service"; @@ -73,6 +74,7 @@ import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-pr BankCodesResolver, TimesheetApprovalService, EmployeeTimesheetResolver, + GetOverviewService, PayPeriodsQueryService, PayPeriodsCommandService, CsvExportService, diff --git a/src/time-and-attendance/utils/selects.utils.ts b/src/time-and-attendance/utils/selects.utils.ts index 2e40431..eec4c0b 100644 --- a/src/time-and-attendance/utils/selects.utils.ts +++ b/src/time-and-attendance/utils/selects.utils.ts @@ -13,7 +13,7 @@ export const expense_select = { comment: true, supervisor_comment: true, is_approved: true, - + } satisfies Prisma.ExpensesSelect; export const shift_select = { @@ -61,4 +61,25 @@ export const timesheet_select = { expense: true, start_date: true, is_approved: true, -} satisfies Prisma.TimesheetsSelect; \ No newline at end of file +} satisfies Prisma.TimesheetsSelect; + +export const select_employee_timesheet = { + timesheet: { + select: { + id: true, + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + }, +}; \ No newline at end of file