diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index 11bef7f..cb3b1bb 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -5,7 +5,7 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; -import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { DayExpensesDto } from "src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto"; import { ExpensesQueryService } from "../services/expenses-query.service"; @ApiTags('Expenses') diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 9a56fa8..441b9d8 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; -import { round2, toUTCDateOnly } from "src/modules/timesheets/utils-helpers-others/timesheet.helpers"; -import { EXPENSE_TYPES } from "src/modules/timesheets/utils-helpers-others/timesheet.types"; +import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto"; +import { round2, toUTCDateOnly } from "src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers"; +import { EXPENSE_TYPES } from "src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.types"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index ef8345f..401b53f 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -4,7 +4,7 @@ import { Module } from "@nestjs/common"; import { PayPeriodsCommandService } from "./services/pay-periods-command.service"; import { PayPeriodsQueryService } from "./services/pay-periods-query.service"; import { TimesheetsModule } from "../timesheets/timesheets.module"; -import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service"; +import { TimesheetsCommandService } from "../timesheets/~misc_deprecated-files/timesheets-command.service"; import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/_deprecated-files/shifts-command.service"; import { SharedModule } from "../shared/shared.module"; diff --git a/src/modules/pay-periods/services/pay-periods-command.service.ts b/src/modules/pay-periods/services/pay-periods-command.service.ts index df9bfed..9fc2caa 100644 --- a/src/modules/pay-periods/services/pay-periods-command.service.ts +++ b/src/modules/pay-periods/services/pay-periods-command.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; -import { TimesheetsCommandService } from "src/modules/timesheets/services/timesheets-command.service"; +import { TimesheetsCommandService } from "src/modules/timesheets/~misc_deprecated-files/timesheets-command.service"; import { PrismaService } from "src/prisma/prisma.service"; import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; import { PayPeriodsQueryService } from "./pay-periods-query.service"; diff --git a/src/modules/shifts/controllers/shift.controller.ts b/src/modules/shifts/controllers/shift.controller.ts index edef7c8..3c5eea7 100644 --- a/src/modules/shifts/controllers/shift.controller.ts +++ b/src/modules/shifts/controllers/shift.controller.ts @@ -1,34 +1,56 @@ -//newer version that uses Express session data -import { Body, Controller, Delete, Param, Patch, Post } from "@nestjs/common"; -import { ShiftsUpsertService, ShiftWithOvertimeDto } from "../services/shifts-upsert.service"; +import { BadRequestException, Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from "@nestjs/common"; +import { CreateResult, DeleteResult, ShiftsUpsertService, ShiftWithOvertimeDto, UpdateResult } from "../services/shifts-upsert.service"; import { updateShiftDto } from "../dtos/update-shift.dto"; import { ShiftDto } from "../dtos/shift.dto"; +import { ShiftsGetService } from "../services/shifts-get.service"; @Controller('shift') export class ShiftController { constructor( - private readonly upser_service: ShiftsUpsertService, + private readonly upsert_service: ShiftsUpsertService, + private readonly get_service: ShiftsGetService ){} - @Post(':timesheet_id') - create( - @Param('timesheet_id') timesheet_id: number, - @Body()dto: ShiftDto): Promise { - return this.upser_service.createShift(timesheet_id, dto) + @Get("shifts") + async getShiftsByIds( + @Query("shift_ids") shift_ids: string) { + const parsed = shift_ids.split(", ").map(value => Number(value)).filter(Number.isFinite); + return this.get_service.getShiftByShiftId(parsed); } - @Patch(':shift_id') - update( - @Param('shift_id') shift_id: number, - @Body() dto: updateShiftDto): Promise{ - return this.upser_service.updateShift(shift_id, dto); + @Post(':timesheet_id') + createBatch( + @Param('timesheet_id', ParseIntPipe) timesheet_id: number, + @Body()dtos: ShiftDto[]): Promise { + const list = Array.isArray(dtos) ? dtos : []; + if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)') + + return this.upsert_service.createShifts(timesheet_id, dtos) + } + + @Patch() + updateBatch( + @Body() body: { updates: { id: number; dto: updateShiftDto }[] }): Promise{ + const updates = Array.isArray(body?.updates) + ? body.updates.filter(update => Number.isFinite(update?.id) && typeof update.dto === "object") + : []; + if(updates.length === 0) { + throw new BadRequestException(`Body is missing or invalid (update shifts)`); + } + return this.upsert_service.updateShifts(updates); } @Delete(':shift_id') - remove( - @Param('shift_id') shift_id: number){ - return this.upser_service.deleteShift(shift_id); + removeBatch( + @Body() body: { ids: number[] }): Promise { + const ids = Array.isArray(body?.ids) + ? body.ids.filter((value) => Number.isFinite(value)) + : []; + if( ids.length === 0) { + throw new BadRequestException('Body is missing or invalid (delete shifts)'); + } + return this.upsert_service.deleteShifts(ids); } } \ No newline at end of file diff --git a/src/modules/shifts/dtos/get-shift.dto.ts b/src/modules/shifts/dtos/get-shift.dto.ts index 0c9f499..21b479f 100644 --- a/src/modules/shifts/dtos/get-shift.dto.ts +++ b/src/modules/shifts/dtos/get-shift.dto.ts @@ -1,4 +1,3 @@ -//newer version that uses Express session data export class GetShiftDto { timesheet_id: number; bank_code_id: number; diff --git a/src/modules/shifts/dtos/shift.dto.ts b/src/modules/shifts/dtos/shift.dto.ts index 70afc62..65413da 100644 --- a/src/modules/shifts/dtos/shift.dto.ts +++ b/src/modules/shifts/dtos/shift.dto.ts @@ -1,5 +1,3 @@ -//newer version that uses Express session data - import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; export class ShiftDto { diff --git a/src/modules/shifts/dtos/update-shift.dto.ts b/src/modules/shifts/dtos/update-shift.dto.ts index a458afd..ebbbd13 100644 --- a/src/modules/shifts/dtos/update-shift.dto.ts +++ b/src/modules/shifts/dtos/update-shift.dto.ts @@ -1,5 +1,3 @@ -//newer version that uses Express session data - import { PartialType, OmitType } from "@nestjs/swagger"; import { ShiftDto } from "./shift.dto"; diff --git a/src/modules/shifts/services/shifts-get.service.ts b/src/modules/shifts/services/shifts-get.service.ts index 2df51d5..1a79f49 100644 --- a/src/modules/shifts/services/shifts-get.service.ts +++ b/src/modules/shifts/services/shifts-get.service.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { GetShiftDto } from "../dtos/get-shift.dto"; import { toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers"; -import { Shifts } from "@prisma/client"; @Injectable() export class ShiftsGetService { @@ -11,10 +10,13 @@ export class ShiftsGetService { ){} //fetch a shift using shift_id and return all that shift's info - async getShiftByShiftId(shift_id: number): Promise { - const shift = await this.prisma.shifts.findUnique({ - where: { id: shift_id }, + async getShiftByShiftId(shift_ids: number[]): Promise { + if(!Array.isArray(shift_ids) || shift_ids.length === 0) return []; + + const rows = await this.prisma.shifts.findMany({ + where: { id: { in: shift_ids } }, select: { + id: true, timesheet_id: true, bank_code_id: true, date: true, @@ -25,26 +27,30 @@ export class ShiftsGetService { comment: true, } }); - if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`); - - return { - timesheet_id: shift.timesheet_id, - bank_code_id: shift.bank_code_id, - date: toStringFromDate(shift.date), - start_time: toStringFromHHmm(shift.start_time), - end_time: toStringFromHHmm(shift.end_time), - is_remote: shift.is_remote, - is_approved: shift.is_approved, - comment: shift.comment ?? undefined, - }; - } - //finds all shifts in a single day to return an array of shifts - async loadShiftsFromSameDay( timesheet_id: number, date_only: Date, - ): Promise> { - return this.prisma.shifts.findMany({ - where: { timesheet_id, date: date_only }, - include: { bank_code: { select: { type: true } } }, + if(rows.length !== shift_ids.length) { + const found_ids = new Set(rows.map(row => row.id)); + const missing_ids = shift_ids.filter(id => !found_ids.has(id)); + throw new NotFoundException(`Shift(s) not found: ${ missing_ids.join(", ")}`); + } + + const row_by_id = new Map(rows.map(row => [row.id, row])); + + return shift_ids.map((id) => { + const shift = row_by_id.get(id)!; + return { + timesheet_id: shift.timesheet_id, + bank_code_id: shift.bank_code_id, + date: toStringFromDate(shift.date), + start_time: toStringFromHHmm(shift.start_time), + end_time: toStringFromHHmm(shift.end_time), + is_remote: shift.is_remote, + is_approved: shift.is_approved, + comment: shift.comment ?? undefined, + } satisfies GetShiftDto; }); + + + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-upsert.service.ts b/src/modules/shifts/services/shifts-upsert.service.ts index bd3b822..6eb0f1a 100644 --- a/src/modules/shifts/services/shifts-upsert.service.ts +++ b/src/modules/shifts/services/shifts-upsert.service.ts @@ -5,7 +5,6 @@ import { updateShiftDto } from "../dtos/update-shift.dto"; import { PrismaService } from "src/prisma/prisma.service"; import { GetShiftDto } from "../dtos/get-shift.dto"; import { ShiftDto } from "../dtos/shift.dto"; -import { GetFindResult } from "@prisma/client/runtime/library"; type Normalized = { date: Date; start_time: Date; end_time: Date; }; @@ -14,236 +13,375 @@ export type ShiftWithOvertimeDto = { overtime: WeekOvertimeSummary; }; +export type CreateResult = { ok: true; data: ShiftWithOvertimeDto } | { ok: false; error: any }; +export type UpdatePayload = { id: number; dto: updateShiftDto }; +export type UpdateResult = { ok: true; id: number; data: ShiftWithOvertimeDto } | { ok: false; id: number; error: any }; +export type DeleteResult = { ok: true; id: number; overtime: WeekOvertimeSummary } | { ok: false; id: number; error: any }; + +type NormedOk = { index: number; dto: ShiftDto; normed: Normalized }; +type NormedErr = { index: number; error: any }; + +const overlaps = (a: { start: Date; end: Date }, b: { start: Date; end: Date }) => + !(a.end <= b.start || a.start >= b.end); + @Injectable() export class ShiftsUpsertService { - constructor( - private readonly prisma: PrismaService, - private readonly overtime: OvertimeService, - ){} + constructor( + private readonly prisma: PrismaService, + private readonly overtime: OvertimeService, + ) { } //_________________________________________________________________ // CREATE //_________________________________________________________________ //normalized frontend data to match DB - //loads all shift from a selected day to check for overlaping shifts + //loads all shifts from a selected day to check for overlaping shifts //checks for overlaping shifts - //create a new shifts + //create new shifts //calculate overtime - //return an object of type GetShiftDto for the frontend to display async createShift(timesheet_id: number, dto: ShiftDto): Promise { - const normed_shift = this.normalizeShiftDto(dto); - if(normed_shift.end_time <= normed_shift.start_time){ - throw new BadRequestException('end_time must be greater than start_time') - } - - //create the shift with normalized date and times - const {row, summary } = await this.prisma.$transaction(async (tx) => { - const conflict = await tx.shifts.findFirst({ - where: { - timesheet_id, - date: normed_shift.date, - NOT: [ - { end_time: { lte: normed_shift.start_time } }, - { start_time: { gte: normed_shift.end_time } }, - ], - }, - select: { - start_time: true, - end_time: true, - bank_code: { select: { type: true } }, - }, - }); - if(conflict) { - throw new ConflictException({ - error_code: 'SHIFT_OVERLAP', - message: 'New shift overlaps with existing shift(s)', - conflicts: [{ - start_time: toStringFromHHmm(conflict.start_time), - end_time: toStringFromHHmm(conflict.end_time), - type: conflict.bank_code.type ?? 'UNKNWON', - }], - }); + const [result] = await this.createShifts(timesheet_id, [dto]); + if (!result.ok) throw result.error; + return result.data; + } + async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise { + if (!Array.isArray(dtos) || dtos.length === 0) return []; + + const normed_shift: Array = dtos.map((dto, index) => { + try { + const normed = this.normalizeShiftDto(dto); + if (normed.end_time <= normed.start_time) { + return { index, error: new BadRequestException(`end_time must be greater than start_time (index ${index})`) }; + } + return { index, dto, normed }; + } catch (error) { + return { index, error }; } + }); + const ok_items = normed_shift.filter((x): x is NormedOk => "normed" in x); - const row = await tx.shifts.create({ - data: { - timesheet_id, - bank_code_id: dto.bank_code_id, - date: normed_shift.date, - start_time: normed_shift.start_time, - end_time: normed_shift.end_time, - is_remote: dto.is_remote, - comment: dto.comment ?? undefined, - }, - select: { - timesheet_id: true, - bank_code_id: true, - date: true, - start_time: true, - end_time: true, - is_remote: true, - is_approved: true, - comment: true, - }, - }); + const regroup_by_date = new Map(); - const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id , normed_shift.date, tx); - - return {row, summary}; + ok_items.forEach(({ index, normed }) => { + const d = normed.date; + const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + if (!regroup_by_date.has(key)) regroup_by_date.set(key, []); + regroup_by_date.get(key)!.push(index); }); - const shift: GetShiftDto = { - timesheet_id: row.timesheet_id, - bank_code_id: row.bank_code_id, - date: toStringFromDate(row.date), - start_time: toStringFromHHmm(row.start_time), - end_time: toStringFromHHmm(row.end_time), - is_remote: row.is_remote, - is_approved: false, - comment: row.comment ?? undefined, - }; + for (const indices of regroup_by_date.values()) { + const ordered = indices + .map(index => { + const item = normed_shift[index] as NormedOk; + return { index: index, start: item.normed.start_time, end: item.normed.end_time }; + }) + .sort((a, b) => a.start.getTime() - b.start.getTime()); - return {shift ,overtime: summary }; - }; + for (let j = 1; j < ordered.length; j++) { + if (overlaps({ start: ordered[j - 1].start, end: ordered[j - 1].end }, { start: ordered[j].start, end: ordered[j].end })) { + const err = new ConflictException({ + error_code: 'SHIFT_OVERLAP_BATCH', + message: 'New shift overlaps with another shift in the same batch (same day).', + }); + return dtos.map((_dto, key) => + indices.includes(key) + ? ({ ok: false, error: err } as CreateResult) + : ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') }) + ); + } + } + } + return this.prisma.$transaction(async (tx) => { + const results: CreateResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') })); + + + normed_shift.forEach((x, i) => { + if ("error" in x) results[i] = { ok: false, error: x.error }; + }); + + const unique_dates = Array.from(regroup_by_date.keys()).map(ms => new Date(ms)); + const existing_date = new Map(); + for (const d of unique_dates) { + const rows = await tx.shifts.findMany({ + where: { timesheet_id, date: d }, + select: { start_time: true, end_time: true }, + }); + existing_date.set(d.getTime(), rows.map(r => ({ start_time: r.start_time, end_time: r.end_time }))); + } + + for (const item of ok_items) { + const { index, dto, normed } = item; + const dayKey = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime(); + const existing = existing_date.get(dayKey) ?? []; + + const hit = existing.find(e => overlaps({ start: e.start_time, end: e.end_time }, { start: normed.start_time, end: normed.end_time })); + if (hit) { + results[index] = { + ok: false, + error: new ConflictException({ + error_code: 'SHIFT_OVERLAP', + message: 'New shift overlaps with existing shift(s)', + conflicts: [{ + start_time: toStringFromHHmm(hit.start_time), + end_time: toStringFromHHmm(hit.end_time), + type: 'UNKNOWN', + }], + }), + }; + continue; + } + + const row = await tx.shifts.create({ + data: { + timesheet_id, + bank_code_id: dto.bank_code_id, + date: normed.date, + start_time: normed.start_time, + end_time: normed.end_time, + is_remote: dto.is_remote, + comment: dto.comment ?? undefined, + }, + select: { + timesheet_id: true, bank_code_id: true, date: true, + start_time: true, end_time: true, is_remote: true, is_approved: true, comment: true, + }, + }); + + existing.push({ start_time: row.start_time, end_time: row.end_time }); + + const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx); + const shift: GetShiftDto = { + timesheet_id: row.timesheet_id, + bank_code_id: row.bank_code_id, + date: toStringFromDate(row.date), + start_time: toStringFromHHmm(row.start_time), + end_time: toStringFromHHmm(row.end_time), + is_remote: row.is_remote, + is_approved: false, + comment: row.comment ?? undefined, + }; + results[index] = { ok: true, data: { shift, overtime: summary } }; + } + + return results; + }); + } //_________________________________________________________________ // UPDATE //_________________________________________________________________ - // finds existing shift in DB - // verify if shift is already approved + // finds existing shifts in DB + // verify if shifts are already approved // normalized Date and Time format to string // check for valid start and end times // check for overlaping possibility // buil a set of data to manipulate modified data only - // update shift in DB + // update shifts in DB // recalculate overtime after update // return an updated version to display + async updateShift(shift_id: number, dto: updateShiftDto): Promise { - const {row, existing, summary_new } = await this.prisma.$transaction(async (tx) =>{ - //search for original shift using shift_id - const existing = await tx.shifts.findUnique({ - where: { id: shift_id }, - select: { - id: true, - timesheet_id: true, - bank_code_id: true, - date: true, - start_time: true, - end_time: true, - is_remote: true, - is_approved: true, - comment: true, - }, - }); - if(!existing) throw new NotFoundException(`Shift with id: ${shift_id} not found`); - if(existing.is_approved) throw new BadRequestException('Approved shift cannot be updated'); + const [results] = await this.updateShifts([{ id: shift_id, dto }]); + if (!results.ok) throw results.error; + return results.data; + } - const date_string = dto.date ?? toStringFromDate(existing.date); - const start_string = dto.start_time ?? toStringFromHHmm(existing.start_time); - const end_string = dto.end_time ?? toStringFromHHmm(existing.end_time); + async updateShifts(updates: UpdatePayload[]): Promise { + if (!Array.isArray(updates) || updates.length === 0) return []; - const normed_shift: Normalized = { - date: toDateFromString(date_string), - start_time: toHHmmFromString(start_string), - end_time: toHHmmFromString(end_string), - }; - if(normed_shift.end_time <= normed_shift.start_time) throw new BadRequestException('end time must be greater than start time'); - - const conflict = await tx.shifts.findFirst({ - where: { - timesheet_id: existing.timesheet_id, - date: normed_shift.date, - id: { not: shift_id }, - NOT: [ - { end_time: { lte: normed_shift.start_time } }, - { start_time: { gte: normed_shift.end_time } }, - ], - }, - select: { - start_time: true, - end_time: true, - bank_code: { select: { type: true } } - }, - }); - if(conflict) { - throw new ConflictException({ - error_code: 'SHIFT_OVERLAP', - message: 'New shift overlaps with existing shift(s)', - conflicts: [{ - start_time: toStringFromHHmm(conflict.start_time), - end_time: toStringFromHHmm(conflict.end_time), - type: conflict.bank_code?.type ?? 'UNKNOWN', - }], - }); - } - - //partial build, update only modified datas - const data: any = {}; - if(dto.date !== undefined) data.date = normed_shift.date; - if(dto.start_time !== undefined) data.start_time = normed_shift.start_time; - if(dto.end_time !== undefined) data.end_time = normed_shift.end_time; - if(dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id; - if(dto.is_remote !== undefined) data.is_remote = dto.is_remote; - if(dto.comment !== undefined) data.comment = dto.comment ?? null; - - const row = await tx.shifts.update({ - //sends updated data to DB - where: { id: shift_id }, - data, + return this.prisma.$transaction(async (tx) => { + const shift_ids = updates.map(update_shift => update_shift.id); + const rows = await tx.shifts.findMany({ + where: { id: { in: shift_ids } }, select: { + id: true, timesheet_id: true, bank_code_id: true, - date: true, - start_time: true, - end_time: true, - is_remote: true, - is_approved: true, - comment: true, + date: true, + start_time: true, + end_time: true, + is_remote: true, + is_approved: true, + comment: true, }, }); + const regroup_id = new Map(rows.map(r => [r.id, r])); - const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, existing.date, tx); - if(row.date.getTime() !== existing.date.getTime()) { - await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx); + for (const update of updates) { + const existing = regroup_id.get(update.id); + if (!existing) { + return updates.map(exist => exist.id === update.id + ? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateResult) + : ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') })); + } + if (existing.is_approved) { + return updates.map(exist => exist.id === update.id + ? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateResult) + : ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') })); + } } - return {row, existing, summary_new}; + + const planned_updates = updates.map(update => { + const exist_shift = regroup_id.get(update.id)!; + const date_string = update.dto.date ?? toStringFromDate(exist_shift.date); + const start_string = update.dto.start_time ?? toStringFromHHmm(exist_shift.start_time); + const end_string = update.dto.end_time ?? toStringFromHHmm(exist_shift.end_time); + const normed: Normalized = { + date: toDateFromString(date_string), + start_time: toHHmmFromString(start_string), + end_time: toHHmmFromString(end_string), + }; + return { update, exist_shift, normed }; + }); + + const groups = new Map(); + function key(timesheet: number, d: Date) { + const day_date = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return `${timesheet}|${day_date.getTime()}`; + } + + const unique_pairs = new Map(); + for (const { exist_shift, normed } of planned_updates) { + unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date }); + unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date }); + } + + for (const group of unique_pairs.values()) { + const day_date = new Date(group.date.getFullYear(), group.date.getMonth(), group.date.getDate()); + const existing = await tx.shifts.findMany({ + where: { timesheet_id: group.timesheet_id, date: day_date }, + select: { id: true, start_time: true, end_time: true }, + }); + groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time })), incoming: planned_updates }); + } + + for (const planned of planned_updates) { + const keys = key(planned.exist_shift.timesheet_id, planned.normed.date); + const group = groups.get(keys)!; + + const conflict = group.existing.find(row => + row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end }, { start: planned.normed.start_time, end: planned.normed.end_time }) + ); + if (conflict) { + return updates.map(exist => + exist.id === planned.exist_shift.id + ? ({ + ok: false, id: exist.id, error: new ConflictException({ + error_code: 'SHIFT_OVERLAP', + message: 'New shift overlaps with existing shift(s)', + conflicts: [{ start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), type: 'UNKNOWN' }], + }) + } as UpdateResult) + : ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') }) + ); + } + } + + const regoup_by_day = new Map(); + for (const planned of planned_updates) { + const keys = key(planned.exist_shift.timesheet_id, planned.normed.date); + if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []); + regoup_by_day.get(keys)!.push({ id: planned.exist_shift.id, start: planned.normed.start_time, end: planned.normed.end_time }); + } + for (const arr of regoup_by_day.values()) { + arr.sort((a, b) => a.start.getTime() - b.start.getTime()); + for (let i = 1; i < arr.length; i++) { + if (overlaps({ start: arr[i - 1].start, end: arr[i - 1].end }, { start: arr[i].start, end: arr[i].end })) { + const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'Overlaps between updates within the same day.' }); + return updates.map(exist => ({ ok: false, id: exist.id, error: error })); + } + } + } + + const results: UpdateResult[] = []; + for (const planned of planned_updates) { + const data: any = {}; + const { dto } = planned.update; + if (dto.date !== undefined) data.date = planned.normed.date; + if (dto.start_time !== undefined) data.start_time = planned.normed.start_time; + if (dto.end_time !== undefined) data.end_time = planned.normed.end_time; + if (dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id; + if (dto.is_remote !== undefined) data.is_remote = dto.is_remote; + if (dto.comment !== undefined) data.comment = dto.comment ?? null; + + const row = await tx.shifts.update({ + where: { id: planned.exist_shift.id }, + data, + select: { + timesheet_id: true, + bank_code_id: true, + date: true, + start_time: true, + end_time: true, + is_remote: true, + is_approved: true, + comment: true, + }, + }); + + const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, planned.exist_shift.date, tx); + if (row.date.getTime() !== planned.exist_shift.date.getTime()) { + await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx); + } + + const shift: GetShiftDto = { + timesheet_id: row.timesheet_id, + bank_code_id: row.bank_code_id, + date: toStringFromDate(row.date), + start_time: toStringFromHHmm(row.start_time), + end_time: toStringFromHHmm(row.end_time), + is_approved: row.is_approved, + is_remote: row.is_remote, + comment: row.comment ?? undefined, + }; + + results.push({ ok: true, id: planned.exist_shift.id, data: { shift, overtime: summary_new } }); + } + return results; }); - const shift: GetShiftDto = { - timesheet_id: row.timesheet_id, - bank_code_id: row.bank_code_id, - date: toStringFromDate(row.date), - start_time: toStringFromHHmm(row.start_time), - end_time: toStringFromHHmm(row.end_time), - is_approved: row.is_approved, - is_remote: row.is_remote, - comment: row.comment ?? undefined, - } - //returns updated shift to frontend - return { shift, overtime: summary_new }; - } //_________________________________________________________________ // DELETE //_________________________________________________________________ - //finds a shift using shit_id + //finds shifts using shit_ids //recalc overtime shifts after delete + //blocs deletion if approved async deleteShift(shift_id: number) { - return await this.prisma.$transaction(async (tx) =>{ - const shift = await tx.shifts.findUnique({ - where: { id: shift_id }, - select: { id: true, date: true, timesheet_id: true }, + const [result] = await this.deleteShifts([shift_id]); + if (!result.ok) throw result.error; + return { success: true, overtime: result.overtime }; + } + async deleteShifts(shift_ids: number[]): Promise { + if (!Array.isArray(shift_ids) || shift_ids.length === 0) return []; + + return this.prisma.$transaction(async (tx) => { + const rows = await tx.shifts.findMany({ + where: { id: { in: shift_ids } }, + select: { id: true, date: true, timesheet_id: true, is_approved: true }, }); - if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`); + const byId = new Map(rows.map(row => [row.id, row])); - await tx.shifts.delete({ where: { id: shift_id } }); + for (const id of shift_ids) { + const row = byId.get(id); + if (!row) { + return shift_ids.map(existing_shift_id => + existing_shift_id === id + ? ({ ok: false, id: existing_shift_id, error: new NotFoundException(`Shift with id #${existing_shift_id} not found`) } as DeleteResult) + : ({ ok: false, id: existing_shift_id, error: new BadRequestException('Batch aborted due to missing shift') }) + ); + } + } - const summary = await this.overtime.getWeekOvertimeSummary( shift.timesheet_id, shift.date, tx); - return { - success: true, - overtime: summary - }; + const results: DeleteResult[] = []; + for (const id of shift_ids) { + const row = byId.get(id)!; + await tx.shifts.delete({ where: { id } }); + const summary = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx); + results.push({ ok: true, id, overtime: summary }); + } + return results; }); } @@ -252,9 +390,9 @@ export class ShiftsUpsertService { //_________________________________________________________________ //converts all string hours and date to Date and HHmm formats private normalizeShiftDto = (dto: ShiftDto): Normalized => { - const date = toDateFromString(dto.date); + const date = toDateFromString(dto.date); const start_time = toHHmmFromString(dto.start_time); - const end_time = toHHmmFromString(dto.end_time); - return { date, start_time, end_time }; + const end_time = toHHmmFromString(dto.end_time); + return { date, start_time, end_time }; } } \ No newline at end of file diff --git a/src/modules/timesheets/controllers/timesheet.controller.ts b/src/modules/timesheets/controllers/timesheet.controller.ts new file mode 100644 index 0000000..8289902 --- /dev/null +++ b/src/modules/timesheets/controllers/timesheet.controller.ts @@ -0,0 +1,9 @@ +import { Controller} from "@nestjs/common"; + +@Controller('timesheet') +export class TimesheetController { + constructor(){} + + + +} \ No newline at end of file diff --git a/src/modules/shared/classes/timesheet.dto.ts b/src/modules/timesheets/dtos/timesheet.dto.ts similarity index 96% rename from src/modules/shared/classes/timesheet.dto.ts rename to src/modules/timesheets/dtos/timesheet.dto.ts index 6024048..8b51f30 100644 --- a/src/modules/shared/classes/timesheet.dto.ts +++ b/src/modules/timesheets/dtos/timesheet.dto.ts @@ -1,7 +1,8 @@ -export class Session { - user_id: number; - -} + + + + + export class Timesheets { employee_fullname: string; timesheets: Timesheet[]; diff --git a/src/modules/timesheets/services/timesheet-get-overview.service.ts b/src/modules/timesheets/services/timesheet-get-overview.service.ts new file mode 100644 index 0000000..dba5876 --- /dev/null +++ b/src/modules/timesheets/services/timesheet-get-overview.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; + + +@Injectable() +export class GetTimesheetsOverviewService { + constructor(private readonly prisma: PrismaService){} + + +} \ No newline at end of file diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index baa507e..e49b0b2 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -1,29 +1,27 @@ -import { TimesheetsController } from './controllers/timesheets.controller'; -import { TimesheetsQueryService } from './services/timesheets-query.service'; -import { TimesheetArchiveService } from './services/timesheet-archive.service'; -import { TimesheetsCommandService } from './services/timesheets-command.service'; -import { ShiftsCommandService } from '../shifts/_deprecated-files/shifts-command.service'; -import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; -import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; -import { SharedModule } from '../shared/shared.module'; -import { Module } from '@nestjs/common'; -import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors'; -import { ShiftsHelpersService } from '../shifts/_deprecated-files/shifts.helpers'; +import { TimesheetsController } from './~misc_deprecated-files/timesheets.controller'; +import { TimesheetsQueryService } from './~misc_deprecated-files/timesheets-query.service'; +import { TimesheetArchiveService } from './services/timesheet-archive.service'; +import { TimesheetsCommandService } from './~misc_deprecated-files/timesheets-command.service'; +import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { SharedModule } from '../shared/shared.module'; +import { Module } from '@nestjs/common'; +import { ShiftsGetService } from '../shifts/services/shifts-get.service'; +import { TimesheetSelectorsService } from './~misc_deprecated-files/utils-helpers-others/timesheet.selectors'; @Module({ imports: [ BusinessLogicsModule, SharedModule, + ShiftsGetService, ], controllers: [TimesheetsController], providers: [ TimesheetsQueryService, TimesheetsCommandService, - ShiftsCommandService, ExpensesCommandService, TimesheetArchiveService, TimesheetSelectorsService, - ShiftsHelpersService, ], exports: [ TimesheetsQueryService, diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/~misc_deprecated-files/create-timesheet.dto.ts similarity index 100% rename from src/modules/timesheets/dtos/create-timesheet.dto.ts rename to src/modules/timesheets/~misc_deprecated-files/create-timesheet.dto.ts diff --git a/src/modules/timesheets/dtos/search-timesheet.dto.ts b/src/modules/timesheets/~misc_deprecated-files/search-timesheet.dto.ts similarity index 100% rename from src/modules/timesheets/dtos/search-timesheet.dto.ts rename to src/modules/timesheets/~misc_deprecated-files/search-timesheet.dto.ts diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto.ts similarity index 100% rename from src/modules/timesheets/dtos/timesheet-period.dto.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto.ts diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/~misc_deprecated-files/timesheets-command.service.ts similarity index 96% rename from src/modules/timesheets/services/timesheets-command.service.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheets-command.service.ts index 179a21a..d9f7930 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/~misc_deprecated-files/timesheets-command.service.ts @@ -1,15 +1,15 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; -import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers"; +import { parseISODate, parseHHmm } from "./utils-helpers-others/timesheet.helpers"; import { TimesheetsQueryService } from "./timesheets-query.service"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { Prisma, Timesheets } from "@prisma/client"; -import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; +import { CreateTimesheetDto } from "./create-timesheet.dto"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; -import { TimesheetMap } from "../utils-helpers-others/timesheet.types"; +import { TimesheetMap } from "./utils-helpers-others/timesheet.types"; import { Shift, Expense } from "../dtos/timesheet.dto"; @Injectable() diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/~misc_deprecated-files/timesheets-query.service.ts similarity index 84% rename from src/modules/timesheets/services/timesheets-query.service.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheets-query.service.ts index 67149ef..bf09330 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/~misc_deprecated-files/timesheets-query.service.ts @@ -1,12 +1,12 @@ -import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from '../utils-helpers-others/timesheet.mappers'; -import { buildPeriod, computeWeekRange } from '../utils-helpers-others/timesheet.utils'; -import { TimesheetSelectorsService } from '../utils-helpers-others/timesheet.selectors'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import { toRangeFromPeriod } from '../utils-helpers-others/timesheet.helpers'; +import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from './utils-helpers-others/timesheet.mappers'; +import { buildPeriod, computeWeekRange } from './utils-helpers-others/timesheet.utils'; +import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors'; +import { TimesheetPeriodDto } from './timesheet-period.dto'; +import { toRangeFromPeriod } from './utils-helpers-others/timesheet.helpers'; import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; import { PrismaService } from 'src/prisma/prisma.service'; -import { TimesheetMap } from '../utils-helpers-others/timesheet.types'; +import { TimesheetMap } from './utils-helpers-others/timesheet.types'; import { Injectable } from '@nestjs/common'; diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts similarity index 83% rename from src/modules/timesheets/controllers/timesheets.controller.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts index 4abca29..f6049a0 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts @@ -1,12 +1,12 @@ import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; -import { TimesheetsQueryService } from '../services/timesheets-query.service'; -import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; +import { TimesheetsQueryService } from './timesheets-query.service'; +import { CreateWeekShiftsDto } from './create-timesheet.dto'; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { TimesheetsCommandService } from '../services/timesheets-command.service'; -import { TimesheetMap } from '../utils-helpers-others/timesheet.types'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { TimesheetsCommandService } from './timesheets-command.service'; +import { TimesheetMap } from './utils-helpers-others/timesheet.types'; +import { TimesheetPeriodDto } from './timesheet-period.dto'; @ApiTags('Timesheets') diff --git a/src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers.ts similarity index 97% rename from src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts rename to src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers.ts index 9e0ca82..b05a520 100644 --- a/src/modules/timesheets/utils-helpers-others/timesheet.helpers.ts +++ b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers.ts @@ -1,5 +1,5 @@ import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant"; -import { DAY_KEYS, DayKey } from "././timesheet.types"; +import { DAY_KEYS, DayKey } from "./timesheet.types"; export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); diff --git a/src/modules/timesheets/utils-helpers-others/timesheet.mappers.ts b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.mappers.ts similarity index 100% rename from src/modules/timesheets/utils-helpers-others/timesheet.mappers.ts rename to src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.mappers.ts diff --git a/src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.selectors.ts similarity index 86% rename from src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts rename to src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.selectors.ts index ec082ad..42aeff1 100644 --- a/src/modules/timesheets/utils-helpers-others/timesheet.selectors.ts +++ b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.selectors.ts @@ -1,7 +1,7 @@ -import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../shared/selects/expenses.select"; +import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../../shared/selects/expenses.select"; import { Injectable, NotFoundException } from "@nestjs/common"; -import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../shared/selects/shifts.select"; -import { PAY_PERIOD_SELECT } from "../../shared/selects/pay-periods.select"; +import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../../shared/selects/shifts.select"; +import { PAY_PERIOD_SELECT } from "../../../shared/selects/pay-periods.select"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() diff --git a/src/modules/timesheets/utils-helpers-others/timesheet.types.ts b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.types.ts similarity index 100% rename from src/modules/timesheets/utils-helpers-others/timesheet.types.ts rename to src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.types.ts diff --git a/src/modules/timesheets/utils-helpers-others/timesheet.utils.ts b/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.utils.ts similarity index 100% rename from src/modules/timesheets/utils-helpers-others/timesheet.utils.ts rename to src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.utils.ts