From 83792e596a504b2b922e73bcbdf2fdc4d5d17b62 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 8 Oct 2025 16:45:37 -0400 Subject: [PATCH] feat(schedule_presets): module schedule_presets setup. Ajustments to seeders to match new realities --- docs/swagger/swagger-spec.json | 66 +++++ .../migration.sql | 48 ++++ prisma/mock-seeds-scripts/02-users.ts | 184 ++++++++++--- prisma/mock-seeds-scripts/12-expenses.ts | 14 +- prisma/schema.prisma | 257 +++++++++++------- src/app.module.ts | 4 +- .../controller/schedule-presets.controller.ts | 31 +++ .../dtos/create-schedule-preset-shifts.dto.ts | 26 ++ .../dtos/create-schedule-presets.dto.ts | 15 + .../schedule-presets.module.ts | 23 ++ .../schedule-presets-apply.service.ts | 45 +++ .../schedule-presets-command.service.ts | 238 ++++++++++++++++ .../schedule-presets-query.service.ts | 51 ++++ .../types/schedule-presets.types.ts | 21 ++ .../shared/constants/regex.constant.ts | 2 + .../shared/types/upsert-actions.types.ts | 1 + 16 files changed, 884 insertions(+), 142 deletions(-) create mode 100644 prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql create mode 100644 src/modules/schedule-presets/controller/schedule-presets.controller.ts create mode 100644 src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts create mode 100644 src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts create mode 100644 src/modules/schedule-presets/schedule-presets.module.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-apply.service.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-command.service.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-query.service.ts create mode 100644 src/modules/schedule-presets/types/schedule-presets.types.ts create mode 100644 src/modules/shared/constants/regex.constant.ts create mode 100644 src/modules/shared/types/upsert-actions.types.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 106add4..01572f2 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1072,6 +1072,68 @@ "pay-periods" ] } + }, + "/schedule-presets/{email}": { + "put": { + "operationId": "SchedulePresetsController_upsert", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulePresetsDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + }, + "get": { + "operationId": "SchedulePresetsController_findListByEmail", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } } }, "info": { @@ -1584,6 +1646,10 @@ "label", "employees_overview" ] + }, + "SchedulePresetsDto": { + "type": "object", + "properties": {} } } } diff --git a/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql new file mode 100644 index 0000000..4bc5fd9 --- /dev/null +++ b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "Weekday" AS ENUM ('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'); + +-- AlterTable +ALTER TABLE "preferences" ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "preferences_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "schedule_presets" ( + "id" SERIAL NOT NULL, + "employee_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "is_default" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "schedule_presets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "schedule_preset_shifts" ( + "id" SERIAL NOT NULL, + "preset_id" INTEGER NOT NULL, + "bank_code_id" INTEGER NOT NULL, + "sort_order" INTEGER NOT NULL, + "start_time" TIME(0) NOT NULL, + "end_time" TIME(0) NOT NULL, + "is_remote" BOOLEAN NOT NULL DEFAULT false, + "week_day" "Weekday" NOT NULL, + + CONSTRAINT "schedule_preset_shifts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_presets_employee_id_name_key" ON "schedule_presets"("employee_id", "name"); + +-- CreateIndex +CREATE INDEX "schedule_preset_shifts_preset_id_week_day_idx" ON "schedule_preset_shifts"("preset_id", "week_day"); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_preset_shifts_preset_id_week_day_sort_order_key" ON "schedule_preset_shifts"("preset_id", "week_day", "sort_order"); + +-- AddForeignKey +ALTER TABLE "schedule_presets" ADD CONSTRAINT "schedule_presets_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_preset_id_fkey" FOREIGN KEY ("preset_id") REFERENCES "schedule_presets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_bank_code_id_fkey" FOREIGN KEY ("bank_code_id") REFERENCES "bank_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 04ec7e4..81e30da 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -3,41 +3,46 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); // base sans underscore, en string -const BASE_PHONE = "1100000000"; +const BASE_PHONE = '1100000000'; function emailFor(i: number) { return `user${i + 1}@example.test`; } async function main() { - const usersData: { + type UserSeed = { first_name: string; last_name: string; email: string; phone_number: string; residence?: string | null; role: Roles; - }[] = []; + }; - const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; - const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark']; + const usersData: UserSeed[] = []; - const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey']; + const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark']; - // 40 employees, avec une distribution initiale - const rolesForEmployees: Roles[] = [ - Roles.ADMIN, - ...Array(4).fill(Roles.SUPERVISOR), // 4 superviseurs - Roles.HR, - Roles.ACCOUNTING, - ...Array(33).fill(Roles.EMPLOYEE), + const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + + /** + * Objectif total: 50 users + * - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33) + * - +1 superviseur spécial "User Test" (=> 40 employés) + * - 10 customers + */ + const rolesForEmployees39: Roles[] = [ + Roles.ADMIN, // 1 + ...Array(3).fill(Roles.SUPERVISOR), // 3 supervisors (le 4e sera "User Test") + Roles.HR, // 1 + Roles.ACCOUNTING, // 1 + ...Array(33).fill(Roles.EMPLOYEE), // 33 + // total = 39 ]; - // --- Normalisation : forcer user5@example.test à SUPERVISOR --- - // user5 => index 4 (i = 4) - rolesForEmployees[4] = Roles.SUPERVISOR; - - for (let i = 0; i < 40; i++) { + // --- 39 employés génériques: user1..user39@example.test + for (let i = 0; i < 39; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ @@ -46,12 +51,12 @@ async function main() { email: emailFor(i), phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', - role: rolesForEmployees[i], + role: rolesForEmployees39[i], }); } - // 10 customers - for (let i = 40; i < 50; i++) { + // --- 10 customers: user40..user49@example.test + for (let i = 39; i < 49; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ @@ -64,29 +69,132 @@ async function main() { }); } - // 1) Insert (sans doublons) + // 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs) await prisma.users.createMany({ data: usersData, skipDuplicates: true }); - // 2) Validation/Correction post-insert : - // - garantir que user5@example.test est SUPERVISOR - // - si jamais le projet avait un user avec la typo, on tente aussi de le corriger (fallback) - const targetEmails = ['user5@example.test', 'user5@examplte.tset']; - for (const email of targetEmails) { - try { - await prisma.users.update({ - where: { email }, - data: { role: Roles.SUPERVISOR }, + // 2) Upsert du superviseur spécial "User Test" + const specialEmail = 'user@targointernet.com'; + const specialUser = await prisma.users.upsert({ + where: { email: specialEmail }, + update: { + first_name: 'User', + last_name: 'Test', + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + create: { + first_name: 'User', + last_name: 'Test', + email: specialEmail, + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + }); + + // 3) Créer/mettre à jour les entrées Employees pour tous les rôles employés + const employeeUsers = await prisma.users.findMany({ + where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } }, + orderBy: { email: 'asc' }, + }); + + const firstWorkDay = new Date('2025-01-06'); // à adapter à ton contexte + + for (let i = 0; i < employeeUsers.length; i++) { + const u = employeeUsers[i]; + await prisma.employees.upsert({ + where: { user_id: u.id }, + update: { + is_supervisor: u.role === Roles.SUPERVISOR, + job_title: u.role, + }, + create: { + user_id: u.id, + is_supervisor: u.role === Roles.SUPERVISOR, + external_payroll_id: 1000 + i, // à adapter + company_code: 1, // à adapter + first_work_day: firstWorkDay, + job_title: u.role, + }, + }); + } + + // 4) Répartition des 33 EMPLOYEE sur 4 superviseurs: 8/8/8/9 (9 pour User Test) + const supervisors = await prisma.employees.findMany({ + where: { is_supervisor: true, user: { role: Roles.SUPERVISOR } }, + include: { user: true }, + orderBy: { id: 'asc' }, + }); + + const userTestSupervisor = supervisors.find((s) => s.user.email === specialEmail); + if (!userTestSupervisor) { + throw new Error('Employee(User Test) introuvable — vérifie le upsert Users/Employees.'); + } + + const plainEmployees = await prisma.employees.findMany({ + where: { is_supervisor: false, user: { role: Roles.EMPLOYEE } }, + orderBy: { id: 'asc' }, + }); + + // Si la configuration est bien 4 superviseurs + 33 employés, on force 8/8/8/9 avec 9 pour User Test. + if (supervisors.length === 4 && plainEmployees.length === 33) { + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + // ordre: autres (3) puis User Test en dernier (reçoit 9) + const ordered = [...others, userTestSupervisor]; + + const chunks = [ + plainEmployees.slice(0, 8), // -> sup 0 + plainEmployees.slice(8, 16), // -> sup 1 + plainEmployees.slice(16, 24), // -> sup 2 + plainEmployees.slice(24, 33), // -> sup 3 (User Test) = 9 + ]; + + for (let b = 0; b < chunks.length; b++) { + const sup = ordered[b]; + for (const emp of chunks[b]) { + await prisma.employees.update({ + where: { id: emp.id }, + data: { supervisor_id: sup.id }, + }); + } + } + } else { + // fallback: distribution round-robin si la config diffère + console.warn( + `Répartition fallback (round-robin). Supervisors=${supervisors.length}, Employees=${plainEmployees.length}` + ); + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + const ordered = [...others, userTestSupervisor]; + for (let i = 0; i < plainEmployees.length; i++) { + const sup = ordered[i % ordered.length]; + await prisma.employees.update({ + where: { id: plainEmployees[i].id }, + data: { supervisor_id: sup.id }, }); - console.log(`✓ Validation: ${email} est SUPERVISOR`); - break; // on s'arrête dès qu'on a corrigé l'un des deux - } catch { - // ignore si non trouvé, on tente l'autre } } - // 3) Petite vérif : compter les superviseurs pour sanity check + // 5) Sanity checks + const totalUsers = await prisma.users.count(); const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } }); - console.log(`✓ Users: 50 rows (40 employees, 10 customers) — SUPERVISORS: ${supCount}`); + const empCount = await prisma.users.count({ where: { role: Roles.EMPLOYEE } }); + + const countForUserTest = await prisma.employees.count({ + where: { supervisor_id: userTestSupervisor.id, is_supervisor: false }, + }); + + console.log(`✓ Users total: ${totalUsers} (attendu 50)`); + console.log(`✓ Supervisors: ${supCount} (attendu 4)`); + console.log(`✓ Employees : ${empCount} (attendu 33)`); + console.log(`✓ Employés sous User Test: ${countForUserTest} (attendu 9)`); } -main().finally(() => prisma.$disconnect()); +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 00f6f0c..622b30d 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -42,6 +42,7 @@ function centsToAmountString(cents: number): string { const c = abs % 100; return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; } + // Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { const qmin = Math.ceil(minCents / step); @@ -65,7 +66,7 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // Codes d'EXPENSES (exemples) - const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; + const BANKS = ['G517', 'G503', 'G502', 'G202'] as const; // Précharger les bank codes const bcRows = await prisma.bankCodes.findMany({ @@ -119,19 +120,16 @@ async function main() { // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard let amount: string; switch (code) { - case 'G503': // petites fournitures + case 'G503': // kilométrage amount = rndAmount(1000, 7500); // 10.00 à 75.00 break; - case 'G502': // repas + case 'G502': // per_diem amount = rndAmount(1500, 3000); // 15.00 à 30.00 break; - case 'G202': // essence + case 'G202': // allowance /prime de garde amount = rndAmount(2000, 15000); // 20.00 à 150.00 break; - case 'G234': // hébergement - amount = rndAmount(6000, 25000); // 60.00 à 250.00 - break; - case 'G517': // péages / divers + case 'G517': // expenses default: amount = rndAmount(500, 5000); // 5.00 à 50.00 break; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a45c7b..b223fa3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,46 +23,51 @@ model Users { residence String? role Roles @default(GUEST) - employee Employees? @relation("UserEmployee") - customer Customers? @relation("UserCustomer") - oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") - employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") - customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") - preferences Preferences? @relation("UserPreferences") + employee Employees? @relation("UserEmployee") + customer Customers? @relation("UserCustomer") + oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") + employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") + customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") + preferences Preferences? @relation("UserPreferences") + @@map("users") } model Employees { - id Int @id @default(autoincrement()) - user Users @relation("UserEmployee", fields: [user_id], references: [id]) - user_id String @unique @db.Uuid + id Int @id @default(autoincrement()) + user Users @relation("UserEmployee", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) + supervisor_id Int? + external_payroll_id Int company_code Int first_work_day DateTime @db.Date last_work_day DateTime? @db.Date - job_title String? - is_supervisor Boolean @default(false) + job_title String? + is_supervisor Boolean @default(false) - supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) - supervisor_id Int? - crew Employees[] @relation("EmployeeSupervisor") + crew Employees[] @relation("EmployeeSupervisor") archive EmployeesArchive[] @relation("EmployeeToArchive") timesheet Timesheets[] @relation("TimesheetEmployee") leave_request LeaveRequests[] @relation("LeaveRequestEmployee") supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive") + schedule_presets SchedulePresets[] @relation("SchedulePreset") @@map("employees") } model EmployeesArchive { - id Int @id @default(autoincrement()) - employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) - employee_id Int - archived_at DateTime @default(now()) + id Int @id @default(autoincrement()) + employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) + employee_id Int + user_id String @db.Uuid + user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) + supervisor_id Int? - user_id String @db.Uuid - user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + archived_at DateTime @default(now()) first_name String last_name String job_title String? @@ -71,8 +76,6 @@ model EmployeesArchive { company_code Int first_work_day DateTime @db.Date last_work_day DateTime @db.Date - supervisor_id Int? - supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) @@map("employees_archive") } @@ -92,27 +95,28 @@ model CustomersArchive { id Int @id @default(autoincrement()) customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id]) customer_id Int - archived_at DateTime @default(now()) - user_id String @db.Uuid user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id]) + user_id String @db.Uuid - invoice_id Int? @unique + archived_at DateTime @default(now()) + invoice_id Int? @unique @@map("customers_archive") } model LeaveRequests { - id Int @id @default(autoincrement()) - employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) - employee_id Int - bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) - bank_code_id Int - leave_type LeaveTypes - date DateTime @db.Date - payable_hours Decimal? @db.Decimal(5,2) - requested_hours Decimal? @db.Decimal(5,2) + id Int @id @default(autoincrement()) + employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) + employee_id Int + bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) + bank_code_id Int + comment String + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) approval_status LeaveApprovalStatus @default(PENDING) + leave_type LeaveTypes archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") @@ -122,16 +126,17 @@ model LeaveRequests { } model LeaveRequestsArchive { - id Int @id @default(autoincrement()) - leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) + id Int @id @default(autoincrement()) + leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) leave_request_id Int - archived_at DateTime @default(now()) + + archived_at DateTime @default(now()) employee_id Int - leave_type LeaveTypes - date DateTime @db.Date - payable_hours Decimal? @db.Decimal(5,2) - requested_hours Decimal? @db.Decimal(5,2) + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) comment String + leave_type LeaveTypes approval_status LeaveApprovalStatus @@unique([leave_request_id]) @@ -141,13 +146,13 @@ model LeaveRequestsArchive { //pay-period vue view PayPeriods { - pay_year Int - pay_period_no Int - payday DateTime @db.Date - period_start DateTime @db.Date - period_end DateTime @db.Date - label String - + pay_year Int + pay_period_no Int + label String + payday DateTime @db.Date + period_start DateTime @db.Date + period_end DateTime @db.Date + @@map("pay_period") } @@ -155,8 +160,9 @@ model Timesheets { id Int @id @default(autoincrement()) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee_id Int - start_date DateTime @db.Date - is_approved Boolean @default(false) + + start_date DateTime @db.Date + is_approved Boolean @default(false) shift Shifts[] @relation("ShiftTimesheet") expense Expenses[] @relation("ExpensesTimesheet") @@ -170,25 +176,71 @@ model TimesheetsArchive { id Int @id @default(autoincrement()) timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id]) timesheet_id Int - archive_at DateTime @default(now()) + employee_id Int is_approved Boolean + archive_at DateTime @default(now()) @@map("timesheets_archive") } + + + + + +model SchedulePresets { + id Int @id @default(autoincrement()) + employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id]) + employee_id Int + + name String + is_default Boolean @default(false) + + shifts SchedulePresetShifts[] @relation("SchedulePresetShiftsSchedulePreset") + + @@unique([employee_id, name], name: "unique_preset_name_per_employee") + @@map("schedule_presets") +} + +model SchedulePresetShifts { + id Int @id @default(autoincrement()) + preset SchedulePresets @relation("SchedulePresetShiftsSchedulePreset",fields: [preset_id], references: [id]) + preset_id Int + bank_code BankCodes @relation("SchedulePresetShiftsBankCodes",fields: [bank_code_id], references: [id]) + bank_code_id Int + + sort_order Int + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_remote Boolean @default(false) + week_day Weekday + + @@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order") + @@index([preset_id, week_day]) + @@map("schedule_preset_shifts") +} + + + + + + + + model Shifts { id Int @id @default(autoincrement()) timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - comment String? - date DateTime @db.Date - start_time DateTime @db.Time(0) - end_time DateTime @db.Time(0) - is_approved Boolean @default(false) - is_remote Boolean @default(false) + + date DateTime @db.Date + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_approved Boolean @default(false) + is_remote Boolean @default(false) + comment String? archive ShiftsArchive[] @relation("ShiftsToArchive") @@ -196,16 +248,17 @@ model Shifts { } model ShiftsArchive { - id Int @id @default(autoincrement()) - shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) - shift_id Int - archive_at DateTime @default(now()) - timesheet_id Int - bank_code_id Int - comment String? + id Int @id @default(autoincrement()) + shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) + shift_id Int + date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) + timesheet_id Int + bank_code_id Int + comment String? + archive_at DateTime @default(now()) @@map("shifts_archive") } @@ -217,27 +270,29 @@ model BankCodes { modifier Float bank_code String - shifts Shifts[] @relation("ShiftBankCodes") - expenses Expenses[] @relation("ExpenseBankCodes") - leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + shifts Shifts[] @relation("ShiftBankCodes") + expenses Expenses[] @relation("ExpenseBankCodes") + leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes") @@map("bank_codes") } model Expenses { - id Int @id @default(autoincrement()) - timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) + id Int @id @default(autoincrement()) + timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int - bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) + bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - date DateTime @db.Date - amount Decimal @db.Money - mileage Decimal? - attachment Int? attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + + date DateTime @db.Date + amount Decimal @db.Money + mileage Decimal? comment String - is_approved Boolean @default(false) supervisor_comment String? + is_approved Boolean @default(false) archive ExpensesArchive[] @relation("ExpensesToArchive") @@ -245,17 +300,18 @@ model Expenses { } model ExpensesArchive { - id Int @id @default(autoincrement()) - expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) + id Int @id @default(autoincrement()) + expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) expense_id Int + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + timesheet_id Int archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date amount Decimal? @db.Money mileage Decimal? - attachment Int? - attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String? is_approved Boolean supervisor_comment String? @@ -282,7 +338,7 @@ model OAuthSessions { } model Blobs { - sha256 String @id @db.Char(64) + sha256 String @id @db.Char(64) size Int mime String storage_path String @@ -295,19 +351,20 @@ model Blobs { } model Attachments { - id Int @id @default(autoincrement()) - sha256 String @db.Char(64) - blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade) - owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc - owner_id String //expense_id, employee_id, etc + id Int @id @default(autoincrement()) + blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade) + sha256 String @db.Char(64) + + owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc + owner_id String //expense_id, employee_id, etc original_name String - status AttachmentStatus @default(ACTIVE) + status AttachmentStatus @default(ACTIVE) retention_policy RetentionPolicy created_by String - created_at DateTime @default(now()) + created_at DateTime @default(now()) - expenses Expenses[] @relation("ExpenseAttachment") - expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") @@index([owner_type, owner_id, created_at]) @@index([sha256]) @@ -315,9 +372,10 @@ model Attachments { } model Preferences { - id Int @id @default(autoincrement()) - user Users @relation("UserPreferences", fields: [user_id], references: [id]) - user_id String @unique @db.Uuid + id Int @id @default(autoincrement()) + user Users @relation("UserPreferences", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + notifications Boolean @default(false) dark_mode Boolean @default(false) lang_switch Boolean @default(false) @@ -326,8 +384,7 @@ model Preferences { @@map("preferences") } - -enum AttachmentStatus { +enum AttachmentStatus { ACTIVE DELETED } @@ -360,7 +417,7 @@ enum LeaveTypes { LEGAL // obligations legales comme devoir de juree WEDDING // mariage HOLIDAY // férier - + @@map("leave_types") } @@ -373,3 +430,13 @@ enum LeaveApprovalStatus { @@map("leave_approval_status") } + +enum Weekday { + SUN + MON + TUE + WED + THU + FRI + SAT +} diff --git a/src/app.module.ts b/src/app.module.ts index 407535a..74eb167 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ValidationError } from 'class-validator'; +import { SchedulePresetsModule } from './modules/schedule-presets/schedule-presets.module'; @Module({ imports: [ @@ -43,7 +44,8 @@ import { ValidationError } from 'class-validator'; OauthSessionsModule, PayperiodsModule, PrismaModule, - ScheduleModule.forRoot(), + ScheduleModule.forRoot(), //cronjobs + SchedulePresetsModule, ShiftsModule, TimesheetsModule, UsersModule, diff --git a/src/modules/schedule-presets/controller/schedule-presets.controller.ts b/src/modules/schedule-presets/controller/schedule-presets.controller.ts new file mode 100644 index 0000000..a71f757 --- /dev/null +++ b/src/modules/schedule-presets/controller/schedule-presets.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, NotFoundException, Param, Put, Query } from "@nestjs/common"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { SchedulePresetsQueryService } from "../services/schedule-presets-query.service"; + +@Controller('schedule-presets') +export class SchedulePresetsController { + constructor( + private readonly commandService: SchedulePresetsCommandService, + private readonly queryService: SchedulePresetsQueryService, + ){} + + @Put(':email') + async upsert( + @Param('email') email: string, + @Query('action') action: UpsertAction, + @Body() dto: SchedulePresetsDto, + ) { + const actions: UpsertAction[] = ['create','update','delete']; + if(!actions) throw new NotFoundException(`No action found for ${actions}`) + return this.commandService.upsertSchedulePreset(email, action, dto); + } + + @Get(':email') + async findListByEmail( + @Param('email') email: string, + ) { + return this.queryService.findSchedulePresetsByEmail(email); + } +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts new file mode 100644 index 0000000..33c06cd --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator"; +import { Weekday } from "@prisma/client"; + +export class SchedulePresetShiftsDto { + @IsEnum(Weekday) + week_day!: Weekday; + + @IsInt() + @Min(1) + sort_order!: number; + + @IsString() + type!: string; + + @IsString() + @Matches(HH_MM_REGEX) + start_time!: string; + + @IsString() + @Matches(HH_MM_REGEX) + end_time!: string; + + @IsOptional() + @IsBoolean() + is_remote?: boolean; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts new file mode 100644 index 0000000..7bd822f --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts @@ -0,0 +1,15 @@ +import { ArrayMinSize, IsArray, IsBoolean, IsEmail, IsOptional, IsString } from "class-validator"; +import { SchedulePresetShiftsDto } from "./create-schedule-preset-shifts.dto"; + +export class SchedulePresetsDto { + @IsString() + name!: string; + + @IsBoolean() + @IsOptional() + is_default: boolean; + + @IsArray() + @ArrayMinSize(1) + preset_shifts: SchedulePresetShiftsDto[]; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts new file mode 100644 index 0000000..3bfb06d --- /dev/null +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service"; +import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service"; +import { SchedulePresetsController } from "./controller/schedule-presets.controller"; +import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Module({ + imports: [], + controllers: [SchedulePresetsController], + providers: [ + PrismaService, + SchedulePresetsCommandService, + SchedulePresetsQueryService, + EmployeeIdEmailResolver, + BankCodesResolver, + ], + exports:[ + SchedulePresetsCommandService, + SchedulePresetsQueryService + ], +}) export class SchedulePresetsModule {} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts new file mode 100644 index 0000000..63aee30 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -0,0 +1,45 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ApplyResult } from "../types/schedule-presets.types"; + +// @Injectable() +// export class SchedulePresetsApplyService { +// constructor( +// private readonly prisma: PrismaService, +// private readonly emailResolver: EmployeeIdEmailResolver, +// ) {} + +// async applyToTimesheet( +// email: string, +// preset_name: string, +// start_date_iso: string, +// ): Promise { +// if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); +// if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); + +// const employee_id = await this.emailResolver.findIdByEmail(email); +// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + +// const preset = await this.prisma.schedulePresets.findFirst({ +// where: { employee_id, name: preset_name }, +// include: { +// shifts: { +// orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], +// select: { +// week_day: true, +// sort_order: true, +// start_time: true, +// end_time: true, +// is_remote: true, +// bank_code_id: true, +// }, +// }, +// }, +// }); +// if(!preset) throw new NotFoundException(`Preset ${preset} not found`); + +// const start_date = new Date(`${start_date_iso}T00:00:00.000Z`) + +// } +// } \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-command.service.ts b/src/modules/schedule-presets/services/schedule-presets-command.service.ts new file mode 100644 index 0000000..b3c9e91 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-command.service.ts @@ -0,0 +1,238 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { Prisma, Weekday } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsCommandService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver : BankCodesResolver, + ){} + + //_________________________________________________________________ + // MASTER CRUD FUNCTION + //_________________________________________________________________ + async upsertSchedulePreset( + email: string, + action: UpsertAction, + dto: SchedulePresetsDto, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }>{ + if(!dto.name?.trim()) throw new BadRequestException(`A Name is required`); + + //resolve employee_id using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email: ${email} not found`); + + //DELETE + if(action === 'delete') { + return this.deletePreset(employee_id, dto.name); + } + + if(!Array.isArray(dto.preset_shifts) || dto.preset_shifts.length === 0) { + throw new BadRequestException(`Empty array, no detected shifts`); + } + const shifts_data = await this.resolveAndBuildPresetShifts(dto); + + //CREATE AND UPDATE + if(action === 'create') { + return this.createPreset(employee_id, dto, shifts_data); + } else if (action === 'update') { + return this.updatePreset(employee_id, dto, shifts_data); + } + throw new BadRequestException(`Unknown action: ${ action }`); + } + + //_________________________________________________________________ + // CREATE + //_________________________________________________________________ + private async createPreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id: number; + total_items: number; + }> { + try { + const result = await this.prisma.$transaction(async (tx)=> { + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true }, + data: { is_default: false }, + }); + } + const created = await tx.schedulePresets.create({ + data: { + employee_id, + name: dto.name, + is_default: !!dto.is_default, + shifts: { create: shifts_data}, + }, + include: { shifts: true }, + }); + return created; + }); + return { action: 'create', preset_id: result.id, total_items: result.shifts.length }; + } catch (error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2002') { + throw new ConflictException(`The name ${dto.name} is already used for another schedule preset`); + } + if (error.code === 'P2003' || error.code === 'P2011') { + throw new ConflictException('Invalid constraint on preset shifts'); + } + } + throw error; + } + } + + //_________________________________________________________________ + // UPDATE + //_________________________________________________________________ + private async updatePreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: dto.name }, + select: { id:true, is_default: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${dto.name}" not found`); + + try { + const result = await this.prisma.$transaction(async (tx) => { + if(typeof dto.is_default === 'boolean'){ + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true, NOT: { id: existing.id } }, + data: { is_default: false }, + }); + } + await tx.schedulePresets.update({ + where: { id: existing.id }, + data: { is_default: dto.is_default }, + }); + } + + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + + const create_many_data: Prisma.SchedulePresetShiftsCreateManyInput[] = + shifts_data.map((shift)=> { + if(!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !=='number'){ + throw new NotFoundException(`Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`); + } + const bank_code_id = shift.bank_code.connect.id; + return { + preset_id: existing.id, + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote ?? false, + bank_code_id: bank_code_id, + }; + }); + await tx.schedulePresetShifts.createMany({data: create_many_data}); + + const count = await tx.schedulePresetShifts.count({ where: { preset_id: existing.id } }); + return { id: existing.id, total: count }; + }); + return { action: 'update', preset_id: result.id, total_items: result.total }; + } catch (error: unknown){ + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2003' || error?.code === 'P2011') { + throw new ConflictException(`Invalid constraint on preset shifts`); + } + } + throw error; + } + } + + //_________________________________________________________________ + // DELETE + //_________________________________________________________________ + private async deletePreset( + employee_id: number, + name: string, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name }, + select: { id: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${name}" not found`); + await this.prisma.$transaction(async (tx) => { + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + await tx.schedulePresets.delete({where: { id: existing.id } }); + }); + return { action: 'delete', preset_id: existing.id, total_items: 0 }; + } + + //PRIVATE HELPER + //resolve bank_code_id using type and convert hours to TIME and valid shifts end/start + private async resolveAndBuildPresetShifts( + dto: SchedulePresetsDto + ): Promise{ + + if(!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`); + + const types = Array.from(new Set(dto.preset_shifts.map((shift)=> shift.type))); + const bank_code_set = new Map(); + + for (const type of types) { + const { id } = await this.typeResolver.findByType(type); + bank_code_set.set(type, id) + } + const toTime = (hhmm: string) => new Date(`1970-01-01T${hhmm}:00.000Z`); + + const pair_set = new Set(); + for (const shift of dto.preset_shifts) { + const key = `${shift.week_day}:${shift.sort_order}`; + if (pair_set.has(key)) { + throw new ConflictException(`Duplicate shift for day/order (${shift.week_day}, ${shift.sort_order})`); + } + pair_set.add(key); + } + + const items: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[] = dto.preset_shifts.map((shift)=> { + const bank_code_id = bank_code_set.get(shift.type); + if(!bank_code_id) throw new NotFoundException(`Bank code not found for type ${shift.type}`); + if (!shift.start_time || !shift.end_time) { + throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`); + } + const start = toTime(shift.start_time); + const end = toTime(shift.end_time); + if(end.getTime() <= start.getTime()) { + throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`); + } + + return { + week_day: shift.week_day as Weekday, + sort_order: shift.sort_order, + bank_code: { connect: { id: bank_code_id} }, + start_time: start, + end_time: end, + is_remote: !!shift.is_remote, + }; + }); + return items; + } +} diff --git a/src/modules/schedule-presets/services/schedule-presets-query.service.ts b/src/modules/schedule-presets/services/schedule-presets-query.service.ts new file mode 100644 index 0000000..665fe47 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-query.service.ts @@ -0,0 +1,51 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PresetResponse, ShiftResponse } from "../types/schedule-presets.types"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsQueryService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + ){} + + async findSchedulePresetsByEmail(email:string): Promise { + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + try { + const presets = await this.prisma.schedulePresets.findMany({ + where: { employee_id }, + orderBy: [{is_default: 'desc' }, { name: 'asc' }], + include: { + shifts: { + orderBy: [{week_day:'asc'}, { sort_order: 'asc'}], + include: { bank_code: { select: { type: true } } }, + }, + }, + }); + const hhmm = (date: Date) => date.toISOString().slice(11,16); + + const response: PresetResponse[] = presets.map((preset) => ({ + id: preset.id, + name: preset.name, + is_default: preset.is_default, + shifts: preset.shifts.map((shift)=> ({ + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: hhmm(shift.start_time), + end_time: hhmm(shift.end_time), + is_remote: shift.is_remote, + type: shift.bank_code?.type, + })), + })); + return response; + } catch ( error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError) {} + throw error; + } + } + +} \ No newline at end of file diff --git a/src/modules/schedule-presets/types/schedule-presets.types.ts b/src/modules/schedule-presets/types/schedule-presets.types.ts new file mode 100644 index 0000000..ea2a3cd --- /dev/null +++ b/src/modules/schedule-presets/types/schedule-presets.types.ts @@ -0,0 +1,21 @@ +export type ShiftResponse = { + week_day: string; + sort_order: number; + start_time: string; + end_time: string; + is_remote: boolean; + type: string; +}; + +export type PresetResponse = { + id: number; + name: string; + is_default: boolean; + shifts: ShiftResponse[]; +} + +export type ApplyResult = { + timesheet_id: number; + created: number; + skipped: number; +} \ No newline at end of file diff --git a/src/modules/shared/constants/regex.constant.ts b/src/modules/shared/constants/regex.constant.ts new file mode 100644 index 0000000..30f77c1 --- /dev/null +++ b/src/modules/shared/constants/regex.constant.ts @@ -0,0 +1,2 @@ +const HH_MM_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_ISO_FORMAT = /^\d{4}-\d{2}-\d{2}$/; \ No newline at end of file diff --git a/src/modules/shared/types/upsert-actions.types.ts b/src/modules/shared/types/upsert-actions.types.ts new file mode 100644 index 0000000..9342d75 --- /dev/null +++ b/src/modules/shared/types/upsert-actions.types.ts @@ -0,0 +1 @@ +export type UpsertAction = 'create' | 'update' | 'delete'; \ No newline at end of file