refactor(shifts): refactor of the shift module to use an array of shifts
This commit is contained in:
parent
d1974ea9e3
commit
b7ad300a6e
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<ShiftWithOvertimeDto> {
|
||||
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<ShiftWithOvertimeDto>{
|
||||
return this.upser_service.updateShift(shift_id, dto);
|
||||
@Post(':timesheet_id')
|
||||
createBatch(
|
||||
@Param('timesheet_id', ParseIntPipe) timesheet_id: number,
|
||||
@Body()dtos: ShiftDto[]): Promise<CreateResult[]> {
|
||||
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<UpdateResult[]>{
|
||||
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<DeleteResult[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
//newer version that uses Express session data
|
||||
export class GetShiftDto {
|
||||
timesheet_id: number;
|
||||
bank_code_id: number;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//newer version that uses Express session data
|
||||
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class ShiftDto {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
//newer version that uses Express session data
|
||||
|
||||
import { PartialType, OmitType } from "@nestjs/swagger";
|
||||
import { ShiftDto } from "./shift.dto";
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GetShiftDto> {
|
||||
const shift = await this.prisma.shifts.findUnique({
|
||||
where: { id: shift_id },
|
||||
async getShiftByShiftId(shift_ids: number[]): Promise<GetShiftDto[]> {
|
||||
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<Array<Shifts & { bank_code: { type: string } | null }>> {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ShiftWithOvertimeDto> {
|
||||
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<CreateResult[]> {
|
||||
if (!Array.isArray(dtos) || dtos.length === 0) return [];
|
||||
|
||||
const normed_shift: Array<NormedOk | NormedErr> = 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<number, number[]>();
|
||||
|
||||
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<number, { start_time: Date; end_time: Date }[]>();
|
||||
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<ShiftWithOvertimeDto> {
|
||||
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<UpdateResult[]> {
|
||||
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<string, { existing: { start: Date; end: Date; id: number }[], incoming: typeof planned_updates }>();
|
||||
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<string, { timesheet_id: number; date: Date }>();
|
||||
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<string, { id: number; start: Date; end: Date }[]>();
|
||||
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<DeleteResult[]> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller} from "@nestjs/common";
|
||||
|
||||
@Controller('timesheet')
|
||||
export class TimesheetController {
|
||||
constructor(){}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
export class Session {
|
||||
user_id: number;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export class Timesheets {
|
||||
employee_fullname: string;
|
||||
timesheets: Timesheet[];
|
||||
|
|
@ -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){}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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';
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
|
|
@ -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);
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user