Merge branch 'main' of git.targo.ca:Targo/targo_backend into dev/setup/attachment/MatthieuH

This commit is contained in:
Matthieu Haineault 2025-10-10 09:34:54 -04:00
commit 95786b9e37
136 changed files with 6637 additions and 4531 deletions

File diff suppressed because it is too large Load Diff

78
package-lock.json generated
View File

@ -54,7 +54,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.14.0", "prisma": "^6.17.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
@ -3633,9 +3633,9 @@
} }
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==", "integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"c12": "3.1.0", "c12": "3.1.0",
@ -3645,48 +3645,48 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==", "integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
"devOptional": true "devOptional": true
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==", "integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "6.14.0", "@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/fetch-engine": "6.14.0", "@prisma/fetch-engine": "6.17.0",
"@prisma/get-platform": "6.14.0" "@prisma/get-platform": "6.17.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", "version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==", "integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
"devOptional": true "devOptional": true
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==", "integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "6.14.0", "@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/get-platform": "6.14.0" "@prisma/get-platform": "6.17.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==", "integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"@prisma/debug": "6.14.0" "@prisma/debug": "6.17.0"
} }
}, },
"node_modules/@scarf/scarf": { "node_modules/@scarf/scarf": {
@ -10078,15 +10078,15 @@
} }
}, },
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.1", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==", "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"citty": "^0.1.6", "citty": "^0.1.6",
"consola": "^3.4.2", "consola": "^3.4.2",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"pkg-types": "^2.2.0", "pkg-types": "^2.3.0",
"tinyexec": "^1.0.1" "tinyexec": "^1.0.1"
}, },
"bin": { "bin": {
@ -10595,9 +10595,9 @@
} }
}, },
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.2.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"confbox": "^0.2.2", "confbox": "^0.2.2",
@ -10677,14 +10677,14 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.14.0", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==", "integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.14.0", "@prisma/config": "6.17.0",
"@prisma/engines": "6.14.0" "@prisma/engines": "6.17.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

View File

@ -86,7 +86,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.14.0", "prisma": "^6.17.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[employee_id,start_date]` on the table `timesheets` will be added. If there are existing duplicate values, this will fail.
- Added the required column `start_date` to the `timesheets` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "public"."timesheets" ADD COLUMN "start_date" DATE NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "timesheets_employee_id_start_date_key" ON "public"."timesheets"("employee_id", "start_date");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."shifts" ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,24 @@
/*
Warnings:
- You are about to drop the column `description` on the `expenses` table. All the data in the column will be lost.
- You are about to drop the column `description` on the `expenses_archive` table. All the data in the column will be lost.
- You are about to drop the column `description` on the `shifts` table. All the data in the column will be lost.
- You are about to drop the column `description` on the `shifts_archive` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."expenses" DROP COLUMN "description",
ADD COLUMN "comment" TEXT;
-- AlterTable
ALTER TABLE "public"."expenses_archive" DROP COLUMN "description",
ADD COLUMN "comment" TEXT;
-- AlterTable
ALTER TABLE "public"."shifts" DROP COLUMN "description",
ADD COLUMN "comment" TEXT;
-- AlterTable
ALTER TABLE "public"."shifts_archive" DROP COLUMN "description",
ADD COLUMN "comment" TEXT;

View File

@ -0,0 +1,25 @@
/*
Warnings:
- You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost.
- You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost.
- Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "public"."expenses" DROP COLUMN "attachement",
ADD COLUMN "attachment" INTEGER,
ADD COLUMN "mileage" DECIMAL(65,30),
ALTER COLUMN "comment" SET NOT NULL;
-- AlterTable
ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement",
ADD COLUMN "attachment" INTEGER,
ADD COLUMN "mileage" DECIMAL(65,30),
ALTER COLUMN "amount" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,56 @@
/*
Warnings:
- You are about to drop the column `end_date_time` on the `leave_requests` table. All the data in the column will be lost.
- You are about to drop the column `start_date_time` on the `leave_requests` table. All the data in the column will be lost.
- You are about to drop the column `end_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
- You are about to drop the column `start_date_time` on the `leave_requests_archive` table. All the data in the column will be lost.
- A unique constraint covering the columns `[employee_id,leave_type,date]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[leave_request_id]` on the table `leave_requests_archive` will be added. If there are existing duplicate values, this will fail.
- Added the required column `date` to the `leave_requests` table without a default value. This is not possible if the table is not empty.
- Added the required column `date` to the `leave_requests_archive` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
ALTER TYPE "leave_types" ADD VALUE 'HOLIDAY';
-- AlterTable
ALTER TABLE "leave_requests" DROP COLUMN "end_date_time",
DROP COLUMN "start_date_time",
ADD COLUMN "date" DATE NOT NULL,
ADD COLUMN "payable_hours" DECIMAL(5,2),
ADD COLUMN "requested_hours" DECIMAL(5,2);
-- AlterTable
ALTER TABLE "leave_requests_archive" DROP COLUMN "end_date_time",
DROP COLUMN "start_date_time",
ADD COLUMN "date" DATE NOT NULL,
ADD COLUMN "payable_hours" DECIMAL(5,2),
ADD COLUMN "requested_hours" DECIMAL(5,2);
-- CreateTable
CREATE TABLE "preferences" (
"user_id" UUID NOT NULL,
"notifications" BOOLEAN NOT NULL DEFAULT false,
"dark_mode" BOOLEAN NOT NULL DEFAULT false,
"lang_switch" BOOLEAN NOT NULL DEFAULT false,
"lefty_mode" BOOLEAN NOT NULL DEFAULT false
);
-- CreateIndex
CREATE UNIQUE INDEX "preferences_user_id_key" ON "preferences"("user_id");
-- CreateIndex
CREATE INDEX "leave_requests_employee_id_date_idx" ON "leave_requests"("employee_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_date_key" ON "leave_requests"("employee_id", "leave_type", "date");
-- CreateIndex
CREATE INDEX "leave_requests_archive_employee_id_date_idx" ON "leave_requests_archive"("employee_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "leave_requests_archive_leave_request_id_key" ON "leave_requests_archive"("leave_request_id");
-- AddForeignKey
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -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;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to alter the column `mileage` on the `expenses` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`.
- You are about to alter the column `mileage` on the `expenses_archive` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`.
*/
-- AlterTable
ALTER TABLE "expenses" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2);
-- AlterTable
ALTER TABLE "expenses_archive" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2);

View File

@ -0,0 +1,18 @@
/*
Warnings:
- The `notifications` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- The `dark_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- The `lang_switch` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- The `lefty_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "preferences" DROP COLUMN "notifications",
ADD COLUMN "notifications" INTEGER NOT NULL DEFAULT 0,
DROP COLUMN "dark_mode",
ADD COLUMN "dark_mode" INTEGER NOT NULL DEFAULT 0,
DROP COLUMN "lang_switch",
ADD COLUMN "lang_switch" INTEGER NOT NULL DEFAULT 0,
DROP COLUMN "lefty_mode",
ADD COLUMN "lefty_mode" INTEGER NOT NULL DEFAULT 0;

View File

@ -4,18 +4,19 @@ const prisma = new PrismaClient();
async function main() { async function main() {
const presets = [ const presets = [
// type, categorie, modifier, bank_code // type, categorie, modifier, bank_code
['REGULAR' ,'SHIFT', 1.0 , 'G1'], ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ],
['EVENING' ,'SHIFT', 1.25, 'G43'], ['OVERTIME' ,'SHIFT' , 2 , 'G43' ],
['Emergency','SHIFT', 2 , 'G48'], ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ],
['HOLIDAY' ,'SHIFT', 2.0 , 'G700'], ['EVENING' ,'SHIFT' , 1.25 , 'G56' ],
['SICK' ,'SHIFT' , 1.0 , 'G105'],
['EXPENSES','EXPENSE', 1.0 , 'G517'], ['HOLIDAY' ,'SHIFT' , 1.0 , 'G104'],
['MILEAGE' ,'EXPENSE', 0.72, 'G57'], ['VACATION' ,'SHIFT' , 1.0 , 'G305'],
['PER_DIEM','EXPENSE', 1.0 , 'G502'], ['ON_CALL' ,'EXPENSE' , 1.0 , 'G202'],
['COMMISSION' ,'EXPENSE' , 1.0 , 'G234'],
['SICK' ,'LEAVE', 1.0, 'G105'], ['PER_DIEM' ,'EXPENSE' , 1.0 , 'G502'],
['VACATION' ,'LEAVE', 1.0, 'G305'], ['MILEAGE' ,'EXPENSE' , 0.72 , 'G503'],
['EXPENSES' ,'EXPENSE' , 1.0 , 'G517'],
]; ];
await prisma.bankCodes.createMany({ await prisma.bankCodes.createMany({

View File

@ -1,71 +1,200 @@
import { PrismaClient, Roles } from '@prisma/client'; import { PrismaClient, Roles } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const BASE_PHONE = 1_100_000_000; // < 2_147_483_647
// base sans underscore, en string
const BASE_PHONE = '1100000000';
function emailFor(i: number) { function emailFor(i: number) {
return `user${i + 1}@example.test`; return `user${i + 1}@example.test`;
} }
async function main() { async function main() {
// 50 users total: 40 employees + 10 customers type UserSeed = {
// Roles distribution for the 40 employees:
// 1 ADMIN, 4 SUPERVISOR, 1 HR, 1 ACCOUNTING, 33 EMPLOYEE
// 10 CUSTOMER (non-employees)
const usersData: {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
phone_number: number; phone_number: string;
residence?: string | null; residence?: string | null;
role: Roles; role: Roles;
}[] = []; };
const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; const usersData: UserSeed[] = [];
const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark'];
// helper to pick const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey'];
const pick = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark'];
const rolesForEmployees: Roles[] = [ const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
Roles.ADMIN,
...Array(4).fill(Roles.SUPERVISOR), /**
Roles.HR, * Objectif total: 50 users
Roles.ACCOUNTING, * - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33)
...Array(33).fill(Roles.EMPLOYEE), * - +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
]; ];
// 40 employees // --- 39 employés génériques: user1..user39@example.test
for (let i = 0; i < 40; i++) { for (let i = 0; i < 39; i++) {
const fn = pick(firstNames); const fn = pick(firstNames);
const ln = pick(lastNames); const ln = pick(lastNames);
usersData.push({ usersData.push({
first_name: fn, first_name: fn,
last_name: ln, last_name: ln,
email: emailFor(i), email: emailFor(i),
phone_number: BASE_PHONE + i, phone_number: BASE_PHONE + i.toString(),
residence: Math.random() < 0.5 ? 'QC' : 'ON', residence: Math.random() < 0.5 ? 'QC' : 'ON',
role: rolesForEmployees[i], role: rolesForEmployees39[i],
}); });
} }
// 10 customers // --- 10 customers: user40..user49@example.test
for (let i = 40; i < 50; i++) { for (let i = 39; i < 49; i++) {
const fn = pick(firstNames); const fn = pick(firstNames);
const ln = pick(lastNames); const ln = pick(lastNames);
usersData.push({ usersData.push({
first_name: fn, first_name: fn,
last_name: ln, last_name: ln,
email: emailFor(i), email: emailFor(i),
phone_number: BASE_PHONE + i, phone_number: BASE_PHONE + i.toString(),
residence: Math.random() < 0.5 ? 'QC' : 'ON', residence: Math.random() < 0.5 ? 'QC' : 'ON',
role: Roles.CUSTOMER, role: Roles.CUSTOMER,
}); });
} }
// 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs)
await prisma.users.createMany({ data: usersData, skipDuplicates: true }); await prisma.users.createMany({ data: usersData, skipDuplicates: true });
console.log('✓ Users: 50 rows (40 employees, 10 customers)');
// 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 },
});
}
}
// 5) Sanity checks
const totalUsers = await prisma.users.count();
const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } });
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();
});

View File

@ -12,7 +12,7 @@ function randomPastDate(yearsBack = 3) {
past.setFullYear(now.getFullYear() - yearsBack); past.setFullYear(now.getFullYear() - yearsBack);
const t = randInt(past.getTime(), now.getTime()); const t = randInt(past.getTime(), now.getTime());
const d = new Date(t); const d = new Date(t);
d.setHours(0,0,0,0); d.setHours(0, 0, 0, 0);
return d; return d;
} }
@ -29,7 +29,12 @@ const jobTitles = [
]; ];
function randomTitle() { function randomTitle() {
return jobTitles[randInt(0, jobTitles.length -1)]; return jobTitles[randInt(0, jobTitles.length - 1)];
}
// Sélection aléatoire entre 271583 et 271585
function randomCompanyCode() {
return Math.random() < 0.5 ? 271583 : 271585;
} }
async function main() { async function main() {
@ -38,40 +43,41 @@ async function main() {
orderBy: { email: 'asc' }, orderBy: { email: 'asc' },
}); });
// Create supervisors first // 1) Trouver le user qui sera le superviseur fixe
const supervisorUsers = employeeUsers.filter(u => u.role === Roles.SUPERVISOR); const supervisorUser = await prisma.users.findUnique({
const supervisorEmployeeIds: number[] = []; where: { email: 'user5@example.test' },
});
for (const u of supervisorUsers) { if (!supervisorUser) {
const emp = await prisma.employees.upsert({ throw new Error("Le user 'user5@example.test' n'existe pas !");
where: { user_id: u.id },
update: {},
create: {
user_id: u.id,
external_payroll_id: randInt(10000, 99999),
company_code: randInt(1, 5),
first_work_day: randomPastDate(3),
last_work_day: null,
job_title: randomTitle(),
is_supervisor: true,
},
});
supervisorEmployeeIds.push(emp.id);
} }
// Create remaining employees, assign a random supervisor (admin can have none) // 2) Créer ou récupérer son employee avec is_supervisor = true
const supervisorEmp = await prisma.employees.upsert({
where: { user_id: supervisorUser.id },
update: { is_supervisor: true },
create: {
user_id: supervisorUser.id,
external_payroll_id: randInt(10000, 99999),
company_code: randomCompanyCode(),
first_work_day: randomPastDate(3),
last_work_day: null,
job_title: randomTitle(),
is_supervisor: true,
},
});
// 3) Créer tous les autres employés avec ce superviseur (sauf ADMIN qui na pas de superviseur)
for (const u of employeeUsers) { for (const u of employeeUsers) {
const already = await prisma.employees.findUnique({ where: { user_id: u.id } }); const already = await prisma.employees.findUnique({ where: { user_id: u.id } });
if (already) continue; if (already) continue;
const supervisor_id = const supervisor_id = u.role === Roles.ADMIN ? null : supervisorEmp.id;
u.role === Roles.ADMIN ? null : supervisorEmployeeIds[randInt(0, supervisorEmployeeIds.length - 1)];
await prisma.employees.create({ await prisma.employees.create({
data: { data: {
user_id: u.id, user_id: u.id,
external_payroll_id: randInt(10000, 99999), external_payroll_id: randInt(10000, 99999),
company_code: randInt(1, 5), company_code: randomCompanyCode(),
first_work_day: randomPastDate(3), first_work_day: randomPastDate(3),
last_work_day: null, last_work_day: null,
supervisor_id, supervisor_id,
@ -81,7 +87,7 @@ async function main() {
} }
const total = await prisma.employees.count(); const total = await prisma.employees.count();
console.log(`✓ Employees: ${total} rows (with supervisors linked)`); console.log(`✓ Employees: ${total} rows (supervisor = ${supervisorUser.email})`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -1,5 +1,10 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function daysAgo(n: number) { function daysAgo(n: number) {

View File

@ -1,5 +1,11 @@
// prisma/mock-seeds-scripts/06-customers-archive.ts // prisma/mock-seeds-scripts/06-customers-archive.ts
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {

View File

@ -1,9 +1,14 @@
import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client'; import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function dateOn(y: number, m: number, d: number) { function dateOn(y: number, m: number, d: number) {
// stocke une date (pour @db.Date) à minuit UTC // stocke une date (@db.Date) à minuit UTC
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0)); return new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
} }
@ -14,7 +19,7 @@ async function main() {
const employees = await prisma.employees.findMany({ select: { id: true } }); const employees = await prisma.employees.findMany({ select: { id: true } });
const bankCodes = await prisma.bankCodes.findMany({ const bankCodes = await prisma.bankCodes.findMany({
where: { categorie: 'LEAVE' }, where: { categorie: 'LEAVE' },
select: { id: true }, select: { id: true, type: true },
}); });
if (!employees.length || !bankCodes.length) { if (!employees.length || !bankCodes.length) {
@ -39,30 +44,31 @@ async function main() {
LeaveApprovalStatus.ESCALATED, LeaveApprovalStatus.ESCALATED,
]; ];
const futureMonths = [8, 9, 10, 11, 12]; // Août→Déc (1-based) const futureMonths = [8, 9, 10, 11, 12]; // Août ? Déc. (1-based)
// ✅ typer rows pour éviter never[]
const rows: Prisma.LeaveRequestsCreateManyInput[] = []; const rows: Prisma.LeaveRequestsCreateManyInput[] = [];
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const emp = employees[i % employees.length]; const emp = employees[i % employees.length];
const m = futureMonths[i % futureMonths.length]; const m = futureMonths[i % futureMonths.length];
const start = dateOn(year, m, 5 + i); // 5..14 const date = dateOn(year, m, 5 + i); // 5..14
if (start <= today) continue; // garantir "futur" if (date <= today) continue; // garantir « futur »
const end = Math.random() < 0.5 ? null : dateOn(year, m, 6 + i);
const type = types[i % types.length]; const type = types[i % types.length];
const status = statuses[i % statuses.length]; const status = statuses[i % statuses.length];
const bc = bankCodes[i % bankCodes.length]; const bc = bankCodes[i % bankCodes.length];
const requestedHours = 4 + (i % 5); // 4 ? 8 h
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
rows.push({ rows.push({
employee_id: emp.id, employee_id: emp.id,
bank_code_id: bc.id, bank_code_id: bc.id,
leave_type: type, leave_type: type,
start_date_time: start, date,
end_date_time: end, // ok: Date | null comment: `Future leave #${i + 1} (${bc.type})`,
comment: `Future leave #${i + 1}`,
approval_status: status, approval_status: status,
requested_hours: requestedHours,
payable_hours: payableHours,
}); });
} }
@ -70,7 +76,7 @@ async function main() {
await prisma.leaveRequests.createMany({ data: rows }); await prisma.leaveRequests.createMany({ data: rows });
} }
console.log(` LeaveRequests (future): ${rows.length} rows`); console.log(`? LeaveRequests (future): ${rows.length} rows`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -1,42 +1,69 @@
import { PrismaClient, LeaveTypes, LeaveApprovalStatus, LeaveRequests } from '@prisma/client'; import { PrismaClient, LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)');
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function daysAgo(n:number) { function daysAgo(n: number) {
const d = new Date(); const d = new Date();
d.setUTCDate(d.getUTCDate() - n); d.setUTCDate(d.getUTCDate() - n);
d.setUTCHours(0,0,0,0); d.setUTCHours(0, 0, 0, 0);
return d; return d;
} }
async function main() { async function main() {
const employees = await prisma.employees.findMany({ select: { id: true } }); const employees = await prisma.employees.findMany({ select: { id: true } });
const bankCodes = await prisma.bankCodes.findMany({ select: { id: true }, where: { categorie: 'LEAVE' } }); if (!employees.length) {
throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.');
}
const leaveCodes = await prisma.bankCodes.findMany({
where: { type: { in: ['SICK', 'VACATION', 'HOLIDAY'] } },
select: { id: true, type: true },
});
if (!leaveCodes.length) {
throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes.");
}
const types = Object.values(LeaveTypes);
const statuses = Object.values(LeaveApprovalStatus); const statuses = Object.values(LeaveApprovalStatus);
const created = [] as Array<{ id: number; employee_id: number; leave_type: LeaveTypes; date: Date; comment: string; approval_status: LeaveApprovalStatus; requested_hours: number; payable_hours: number | null }>;
const created: LeaveRequests[] = []; const COUNT = 12;
for (let i = 0; i < COUNT; i++) {
for (let i = 0; i < 10; i++) {
const emp = employees[i % employees.length]; const emp = employees[i % employees.length];
const bc = bankCodes[i % bankCodes.length]; const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)];
const start = daysAgo(120 - i * 3); // tous avant aujourd'hui
const end = Math.random() < 0.4 ? null : daysAgo(119 - i * 3); const date = daysAgo(120 - i * 3);
const status = statuses[(i + 2) % statuses.length];
const requestedHours = 4 + (i % 5); // 4 ? 8 h
const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null;
const lr = await prisma.leaveRequests.create({ const lr = await prisma.leaveRequests.create({
data: { data: {
employee_id: emp.id, employee_id: emp.id,
bank_code_id: bc.id, bank_code_id: leaveCode.id,
leave_type: types[i % types.length], leave_type: leaveCode.type as LeaveTypes,
start_date_time: start, date,
end_date_time: end, comment: `Past leave #${i + 1} (${leaveCode.type})`,
comment: `Past leave #${i+1}`, approval_status: status,
approval_status: statuses[(i+2) % statuses.length], requested_hours: requestedHours,
payable_hours: payableHours,
}, },
}); });
created.push(lr); created.push({
id: lr.id,
employee_id: lr.employee_id,
leave_type: lr.leave_type,
date: lr.date,
comment: lr.comment,
approval_status: lr.approval_status,
requested_hours: requestedHours,
payable_hours: payableHours,
});
} }
for (const lr of created) { for (const lr of created) {
@ -45,15 +72,16 @@ async function main() {
leave_request_id: lr.id, leave_request_id: lr.id,
employee_id: lr.employee_id, employee_id: lr.employee_id,
leave_type: lr.leave_type, leave_type: lr.leave_type,
start_date_time: lr.start_date_time, date: lr.date,
end_date_time: lr.end_date_time,
comment: lr.comment, comment: lr.comment,
approval_status: lr.approval_status, approval_status: lr.approval_status,
requested_hours: lr.requested_hours,
payable_hours: lr.payable_hours,
}, },
}); });
} }
console.log(` LeaveRequestsArchive: ${created.length} rows`); console.log(`? LeaveRequestsArchive: ${created.length} rows`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -2,26 +2,59 @@ import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// ====== Config ======
const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé)
const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante
// Lundi (UTC) de la semaine courante
function mondayOfThisWeekUTC(now = new Date()) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const day = d.getUTCDay(); // 0=Dim, 1=Lun, ...
const diffToMonday = (day + 6) % 7; // 0 si lundi
d.setUTCDate(d.getUTCDate() - diffToMonday);
d.setUTCHours(0, 0, 0, 0);
return d;
}
function mondayNWeeksBefore(monday: Date, n: number) {
const d = new Date(monday);
d.setUTCDate(d.getUTCDate() - n * 7);
return d;
}
async function main() { async function main() {
const employees = await prisma.employees.findMany({ select: { id: true } }); const employees = await prisma.employees.findMany({ select: { id: true } });
if (!employees.length) {
console.warn('Aucun employé — rien à insérer.');
return;
}
// ✅ typer rows pour éviter never[] // Construit la liste des lundis (1 par semaine)
const mondays: Date[] = [];
const mondayThisWeek = mondayOfThisWeekUTC();
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
for (let n = 1; n <= PREVIOUS_WEEKS; n++) {
mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
}
// Prépare les lignes (1 timesheet / employé / semaine)
const rows: Prisma.TimesheetsCreateManyInput[] = []; const rows: Prisma.TimesheetsCreateManyInput[] = [];
// 8 timesheets / employee
for (const e of employees) { for (const e of employees) {
for (let i = 0; i < 8; i++) { for (const monday of mondays) {
const is_approved = Math.random() < 0.3; rows.push({
rows.push({ employee_id: e.id, is_approved }); employee_id: e.id,
start_date: monday,
is_approved: Math.random() < 0.3,
} as Prisma.TimesheetsCreateManyInput);
} }
} }
// Insert en bulk et ignore les doublons si déjà présents
if (rows.length) { if (rows.length) {
await prisma.timesheets.createMany({ data: rows }); await prisma.timesheets.createMany({ data: rows, skipDuplicates: true });
} }
const total = await prisma.timesheets.count(); const total = await prisma.timesheets.count();
console.log(`✓ Timesheets: ${total} rows (added ${rows.length})`); console.log(`✓ Timesheets: ${total} rows (ajout potentiel: ${rows.length}, ${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines)`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -2,54 +2,233 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function timeAt(hour:number, minute:number) { // ====== Config ======
// stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) const PREVIOUS_WEEKS = 5;
const INCLUDE_CURRENT = true;
const INCR = 15; // incrément ferme de 15 minutes (0.25 h)
const DAY_MIN = 5 * 60; // 5h
const DAY_MAX = 11 * 60; // 11h
const HARD_END = 19 * 60 + 30; // 19:30
// ====== Helpers temps ======
function timeAt(hour: number, minute: number) {
return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); return new Date(Date.UTC(1970, 0, 1, hour, minute, 0));
} }
function daysAgo(n:number) { function mondayOfThisWeekUTC(now = new Date()) {
const d = new Date(); const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
d.setUTCDate(d.getUTCDate() - n); const day = d.getUTCDay();
d.setUTCHours(0,0,0,0); const diffToMonday = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diffToMonday);
d.setUTCHours(0, 0, 0, 0);
return d; return d;
} }
function weekDatesFromMonday(monday: Date) {
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() + i);
return d;
});
}
function mondayNWeeksBefore(monday: Date, n: number) {
const d = new Date(monday);
d.setUTCDate(d.getUTCDate() - n * 7);
return d;
}
function rndInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
function addMinutes(h: number, m: number, delta: number) {
const total = h * 60 + m + delta;
const hh = Math.floor(total / 60);
const mm = ((total % 60) + 60) % 60;
return { h: hh, m: mm };
}
// Aligne vers le multiple de INCR le plus proche
function quantize(mins: number): number {
const q = Math.round(mins / INCR) * INCR;
return q;
}
// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes
function rndQuantized(min: number, max: number): number {
const qmin = Math.ceil(min / INCR);
const qmax = Math.floor(max / INCR);
const q = rndInt(qmin, qmax);
return q * INCR;
}
// Helper: garantit le timesheet de la semaine (upsert)
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
return prisma.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date } },
update: {},
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
select: { id: true },
});
}
async function main() { async function main() {
const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'SHIFT' }, select: { id: true } }); // --- Bank codes (pondérés: surtout G1 = régulier) ---
if (!bankCodes.length) throw new Error('Need SHIFT bank codes'); const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const;
const WEIGHTED_CODES = [
'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1',
'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1'
] as const;
const bcRows = await prisma.bankCodes.findMany({
where: { bank_code: { in: BANKS as unknown as string[] } },
select: { id: true, bank_code: true },
});
const bcMap = new Map(bcRows.map(b => [b.bank_code, b.id]));
for (const c of BANKS) {
if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`);
}
const employees = await prisma.employees.findMany({ select: { id: true } }); const employees = await prisma.employees.findMany({ select: { id: true } });
if (!employees.length) {
console.log('Aucun employé — rien à insérer.');
return;
}
for (const e of employees) { const mondayThisWeek = mondayOfThisWeekUTC();
const tss = await prisma.timesheets.findMany({ const mondays: Date[] = [];
where: { employee_id: e.id }, if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
select: { id: true }, for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
});
if (!tss.length) continue;
// 10 shifts / employee let created = 0;
for (let i = 0; i < 10; i++) {
const ts = tss[i % tss.length];
const bc = bankCodes[i % bankCodes.length];
const date = daysAgo(7 + i); // la dernière quinzaine
const startH = 8 + (i % 3); // 8..10
const endH = startH + 7 + (i % 2); // 15..17
await prisma.shifts.create({ for (let wi = 0; wi < mondays.length; wi++) {
data: { const monday = mondays[wi];
timesheet_id: ts.id, const days = weekDatesFromMonday(monday);
bank_code_id: bc.id,
description: `Shift ${i + 1} for emp ${e.id}`, for (let ei = 0; ei < employees.length; ei++) {
date, const e = employees[ei];
start_time: timeAt(startH, 0),
end_time: timeAt(endH, 0), // Cible hebdo 3545h, multiple de 15 min
is_approved: Math.random() < 0.5, const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60);
},
}); // Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...)
const baseStartH = 7 + (ei % 3); // 7,8,9
const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min
// Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min
const plannedDaily: number[] = [];
for (let d = 0; d < 5; d++) {
const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15
const base = 8 * 60 + jitter;
plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX)));
}
// Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15)
const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0);
plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX));
// Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15
let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0);
const step = diff > 0 ? INCR : -INCR;
let guard = 100; // anti-boucle
while (diff !== 0 && guard-- > 0) {
for (let d = 0; d < 5 && diff !== 0; d++) {
const next = plannedDaily[d] + step;
if (next >= DAY_MIN && next <= DAY_MAX) {
plannedDaily[d] = next;
diff -= step;
}
}
}
// Upsert du timesheet (semaine)
const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0]));
for (let di = 0; di < 5; di++) {
const date = days[di];
const targetWorkMin = plannedDaily[di]; // multiple de 15
// Départ ~ base + jitter (par pas de 15 min aussi)
const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30
const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter);
// Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15)
const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60);
const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60);
const lunchStartMin = rndQuantized(earliestLunch, latestLunch);
const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15
const lunchEndMin = lunchStartMin + lunchDur;
// Travail = (lunchStart - start) + (end - lunchEnd)
const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15
let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15)
if (afternoonWork % INCR !== 0) {
// sécurité (ne devrait pas arriver)
afternoonWork = quantize(afternoonWork);
}
// Fin de journée (quantisée par construction)
const endMinRaw = lunchEndMin + afternoonWork;
const endMin = Math.min(endMinRaw, HARD_END);
// Bank codes variés
const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
const bcMorningId = bcMap.get(bcMorningCode)!;
const bcAfternoonId = bcMap.get(bcAfternoonCode)!;
// Shift matin
const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 };
await prisma.shifts.create({
data: {
timesheet_id: ts.id,
bank_code_id: bcMorningId,
comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id}${bcMorningCode}`,
date,
start_time: timeAt(startH, startM),
end_time: timeAt(lunchStartHM.h, lunchStartHM.m),
is_approved: Math.random() < 0.6,
},
});
created++;
// Shift après-midi (si >= 30 min — sera de toute façon multiple de 15)
const pmDuration = endMin - lunchEndMin;
if (pmDuration >= 30) {
const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 };
const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 };
await prisma.shifts.create({
data: {
timesheet_id: ts.id,
bank_code_id: bcAfternoonId,
comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id}${bcAfternoonCode}`,
date,
start_time: timeAt(lunchEndHM.h, lunchEndHM.m),
end_time: timeAt(finalEndHM.h, finalEndHM.m),
is_approved: Math.random() < 0.6,
},
});
created++;
} else {
// Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15)
const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur);
await prisma.shifts.create({
data: {
timesheet_id: ts.id,
bank_code_id: bcMap.get('G1')!,
comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`,
date,
start_time: timeAt(startH, startM),
end_time: timeAt(fallbackEnd.h, fallbackEnd.m),
is_approved: Math.random() < 0.6,
},
});
created++;
}
}
} }
} }
const total = await prisma.shifts.count(); const total = await prisma.shifts.count();
console.log(`✓ Shifts: ${total} total rows`); console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -1,5 +1,10 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function timeAt(h:number,m:number) { function timeAt(h:number,m:number) {
@ -21,7 +26,7 @@ async function main() {
if (!tss.length) continue; if (!tss.length) continue;
const createdShiftIds: number[] = []; const createdShiftIds: number[] = [];
for (let i = 0; i < 30; i++) { for (let i = 0; i < 8; i++) {
const ts = tss[i % tss.length]; const ts = tss[i % tss.length];
const bc = bankCodes[i % bankCodes.length]; const bc = bankCodes[i % bankCodes.length];
const date = daysAgo(200 + i); // bien dans le passé const date = daysAgo(200 + i); // bien dans le passé
@ -32,7 +37,7 @@ async function main() {
data: { data: {
timesheet_id: ts.id, timesheet_id: ts.id,
bank_code_id: bc.id, bank_code_id: bc.id,
description: `Archived-era shift ${i + 1} for emp ${e.id}`, comment: `Archived-era shift ${i + 1} for emp ${e.id}`,
date, date,
start_time: timeAt(startH, 0), start_time: timeAt(startH, 0),
end_time: timeAt(endH, 0), end_time: timeAt(endH, 0),
@ -50,7 +55,7 @@ async function main() {
shift_id: s.id, shift_id: s.id,
timesheet_id: s.timesheet_id, timesheet_id: s.timesheet_id,
bank_code_id: s.bank_code_id, bank_code_id: s.bank_code_id,
description: s.description, comment: s.comment,
date: s.date, date: s.date,
start_time: s.start_time, start_time: s.start_time,
end_time: s.end_time, end_time: s.end_time,

View File

@ -2,43 +2,165 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function daysAgo(n:number) { // ====== Config ======
const d = new Date(); const WEEKS_BACK = 4; // 4 semaines avant + semaine courante
d.setUTCDate(d.getUTCDate() - n); const INCLUDE_CURRENT = true; // inclure la semaine courante
d.setUTCHours(0,0,0,0); const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75)
// ====== Helpers dates ======
function mondayOfThisWeekUTC(now = new Date()) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const day = d.getUTCDay();
const diffToMonday = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diffToMonday);
d.setUTCHours(0, 0, 0, 0);
return d; return d;
} }
function mondayNWeeksBefore(monday: Date, n: number) {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() - n * 7);
return d;
}
// L→V (UTC minuit)
function weekDatesMonToFri(monday: Date) {
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() + i);
return d;
});
}
// ====== Helpers random / amount ======
function rndInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// String "xx.yy" à partir de cents ENTiers (jamais de float)
function centsToAmountString(cents: number): string {
const sign = cents < 0 ? '-' : '';
const abs = Math.abs(cents);
const dollars = Math.floor(abs / 100);
const c = abs % 100;
return `${sign}${dollars}.${c.toString().padStart(2, '0')}`;
}
function to2(value: string): string {
// normalise au cas où (sécurité)
return (Math.round(parseFloat(value) * 100) / 100).toFixed(2);
}
// 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);
const qmax = Math.floor(maxCents / step);
const q = rndInt(qmin, qmax);
return q * step;
}
function rndAmount(minCents: number, maxCents: number): string {
return centsToAmountString(rndQuantizedCents(minCents, maxCents));
}
// ====== Timesheet upsert ======
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
return prisma.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date } },
update: {},
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
select: { id: true },
});
}
async function main() { async function main() {
const expenseCodes = await prisma.bankCodes.findMany({ where: { categorie: 'EXPENSE' }, select: { id: true } }); // Codes d'EXPENSES (exemples)
if (!expenseCodes.length) throw new Error('Need EXPENSE bank codes'); const BANKS = ['G517', 'G503', 'G502', 'G202'] as const;
const timesheets = await prisma.timesheets.findMany({ select: { id: true } }); // Précharger les bank codes
if (!timesheets.length) { const bcRows = await prisma.bankCodes.findMany({
console.warn('No timesheets found; aborting expenses seed.'); where: { bank_code: { in: BANKS as unknown as string[] } },
select: { id: true, bank_code: true },
});
const bcMap = new Map(bcRows.map(c => [c.bank_code, c.id]));
for (const c of BANKS) {
if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`);
}
// Employés
const employees = await prisma.employees.findMany({ select: { id: true } });
if (!employees.length) {
console.warn('Aucun employé — rien à insérer.');
return; return;
} }
// 5 expenses distribuées aléatoirement parmi les employés (via timesheets) // Liste des lundis (courant + 4 précédents)
for (let i = 0; i < 5; i++) { const mondayThisWeek = mondayOfThisWeekUTC();
const ts = timesheets[Math.floor(Math.random() * timesheets.length)]; const mondays: Date[] = [];
const bc = expenseCodes[i % expenseCodes.length]; if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
await prisma.expenses.create({ for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
data: {
timesheet_id: ts.id, let created = 0;
bank_code_id: bc.id,
date: daysAgo(3 + i), for (const monday of mondays) {
amount: (50 + i * 10).toFixed(2), const weekDays = weekDatesMonToFri(monday);
attachement: null, const friday = weekDays[4];
description: `Expense #${i + 1}`,
is_approved: Math.random() < 0.5, for (const e of employees) {
supervisor_comment: Math.random() < 0.3 ? 'OK' : null, // Upsert timesheet pour CETTE semaine/employee
}, const ts = await getOrCreateTimesheet(e.id, monday);
});
// Idempotence: si déjà au moins une expense L→V, on skip la semaine
const already = await prisma.expenses.findFirst({
where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } },
select: { id: true },
});
if (already) continue;
// 1 à 3 expenses (jours distincts)
const count = rndInt(1, 3);
const dayIndexes = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, count);
for (const idx of dayIndexes) {
const date = weekDays[idx];
const code = BANKS[rndInt(0, BANKS.length - 1)];
const bank_code_id = bcMap.get(code)!;
// Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard
let amount: string = '0.00';
let mileage: string = '0.00';
switch (code) {
case 'G503': // kilométrage
mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00
break;
case 'G502': // per_diem
amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00
break;
case 'G202': // on_call /prime de garde
amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00
break;
case 'G517': // expenses
default:
amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00
break;
}
await prisma.expenses.create({
data: {
timesheet_id: ts.id,
bank_code_id,
date,
amount,
mileage,
attachment: null,
comment: `Expense ${code} (emp ${e.id})`,
is_approved: Math.random() < 0.65,
supervisor_comment: Math.random() < 0.25 ? 'OK' : null,
},
});
created++;
}
}
} }
const total = await prisma.expenses.count(); const total = await prisma.expenses.count();
console.log(`✓ Expenses: ${total} total rows`); console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`);
} }
main().finally(() => prisma.$disconnect()); main().finally(() => prisma.$disconnect());

View File

@ -1,6 +1,11 @@
// 13-expenses-archive.ts // 13-expenses-archive.ts
import { PrismaClient, Expenses } from '@prisma/client'; import { PrismaClient, Expenses } from '@prisma/client';
if (process.env.SKIP_LEAVE_REQUESTS === 'true') {
console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)");
process.exit(0);
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function daysAgo(n:number) { function daysAgo(n:number) {
@ -17,7 +22,7 @@ async function main() {
// ✅ typer pour éviter never[] // ✅ typer pour éviter never[]
const created: Expenses[] = []; const created: Expenses[] = [];
for (let i = 0; i < 20; i++) { for (let i = 0; i < 4; i++) {
const ts = timesheets[i % timesheets.length]; const ts = timesheets[i % timesheets.length];
const bc = expenseCodes[i % expenseCodes.length]; const bc = expenseCodes[i % expenseCodes.length];
@ -27,8 +32,8 @@ async function main() {
bank_code_id: bc.id, bank_code_id: bc.id,
date: daysAgo(60 + i), date: daysAgo(60 + i),
amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string
attachement: null, attachment: null,
description: `Old expense #${i + 1}`, comment: `Old expense #${i + 1}`,
is_approved: true, is_approved: true,
supervisor_comment: null, supervisor_comment: null,
}, },
@ -45,8 +50,8 @@ async function main() {
bank_code_id: e.bank_code_id, bank_code_id: e.bank_code_id,
date: e.date, date: e.date,
amount: e.amount, amount: e.amount,
attachement: e.attachement, attachment: e.attachment,
description: e.description, comment: e.comment,
is_approved: e.is_approved, is_approved: e.is_approved,
supervisor_comment: e.supervisor_comment, supervisor_comment: e.supervisor_comment,
}, },

View File

@ -19,50 +19,55 @@ model Users {
first_name String first_name String
last_name String last_name String
email String @unique email String @unique
phone_number Int @unique phone_number String @unique
residence String? residence String?
role Roles @default(GUEST) role Roles @default(GUEST)
employee Employees? @relation("UserEmployee") employee Employees? @relation("UserEmployee")
customer Customers? @relation("UserCustomer") customer Customers? @relation("UserCustomer")
oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") oauth_sessions OAuthSessions[] @relation("UserOAuthSessions")
employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive")
customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") customer_archive CustomersArchive[] @relation("UserToCustomersToArchive")
preferences Preferences? @relation("UserPreferences")
@@map("users") @@map("users")
} }
model Employees { model Employees {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user Users @relation("UserEmployee", fields: [user_id], references: [id]) user Users @relation("UserEmployee", fields: [user_id], references: [id])
user_id String @unique @db.Uuid user_id String @unique @db.Uuid
supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id])
supervisor_id Int?
external_payroll_id Int external_payroll_id Int
company_code Int company_code Int
first_work_day DateTime @db.Date first_work_day DateTime @db.Date
last_work_day DateTime? @db.Date last_work_day DateTime? @db.Date
job_title String? job_title String?
is_supervisor Boolean @default(false) 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") archive EmployeesArchive[] @relation("EmployeeToArchive")
timesheet Timesheets[] @relation("TimesheetEmployee") timesheet Timesheets[] @relation("TimesheetEmployee")
leave_request LeaveRequests[] @relation("LeaveRequestEmployee") leave_request LeaveRequests[] @relation("LeaveRequestEmployee")
supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive") supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive")
schedule_presets SchedulePresets[] @relation("SchedulePreset")
@@map("employees") @@map("employees")
} }
model EmployeesArchive { model EmployeesArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id])
employee_id Int employee_id Int
archived_at DateTime @default(now()) 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 archived_at DateTime @default(now())
user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id])
first_name String first_name String
last_name String last_name String
job_title String? job_title String?
@ -71,8 +76,6 @@ model EmployeesArchive {
company_code Int company_code Int
first_work_day DateTime @db.Date first_work_day DateTime @db.Date
last_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") @@map("employees_archive")
} }
@ -92,56 +95,64 @@ model CustomersArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id]) customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id])
customer_id Int customer_id Int
archived_at DateTime @default(now())
user_id String @db.Uuid
user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id]) 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") @@map("customers_archive")
} }
model LeaveRequests { model LeaveRequests {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id])
employee_id Int employee_id Int
bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int bank_code_id Int
leave_type LeaveTypes
start_date_time DateTime @db.Date
end_date_time DateTime? @db.Date
comment String 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) approval_status LeaveApprovalStatus @default(PENDING)
leave_type LeaveTypes
archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive")
@@unique([employee_id, leave_type, date], name: "leave_per_employee_date")
@@index([employee_id, date])
@@map("leave_requests") @@map("leave_requests")
} }
model LeaveRequestsArchive { model LeaveRequestsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id])
leave_request_id Int leave_request_id Int
archived_at DateTime @default(now())
archived_at DateTime @default(now())
employee_id Int employee_id Int
leave_type LeaveTypes date DateTime @db.Date
start_date_time DateTime @db.Date payable_hours Decimal? @db.Decimal(5, 2)
end_date_time DateTime? @db.Date requested_hours Decimal? @db.Decimal(5, 2)
comment String comment String
leave_type LeaveTypes
approval_status LeaveApprovalStatus approval_status LeaveApprovalStatus
@@unique([leave_request_id])
@@index([employee_id, date])
@@map("leave_requests_archive") @@map("leave_requests_archive")
} }
//pay-period vue //pay-period vue
view PayPeriods { view PayPeriods {
pay_year Int pay_year Int
pay_period_no Int pay_period_no Int
payday DateTime @db.Date label String
period_start DateTime @db.Date payday DateTime @db.Date
period_end DateTime @db.Date period_start DateTime @db.Date
label String period_end DateTime @db.Date
@@map("pay_period") @@map("pay_period")
} }
@ -149,12 +160,15 @@ model Timesheets {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id])
employee_id Int employee_id Int
is_approved Boolean @default(false)
start_date DateTime @db.Date
is_approved Boolean @default(false)
shift Shifts[] @relation("ShiftTimesheet") shift Shifts[] @relation("ShiftTimesheet")
expense Expenses[] @relation("ExpensesTimesheet") expense Expenses[] @relation("ExpensesTimesheet")
archive TimesheetsArchive[] @relation("TimesheetsToArchive") archive TimesheetsArchive[] @relation("TimesheetsToArchive")
@@unique([employee_id, start_date], name: "employee_id_start_date")
@@map("timesheets") @@map("timesheets")
} }
@ -162,24 +176,71 @@ model TimesheetsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id]) timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id])
timesheet_id Int timesheet_id Int
archive_at DateTime @default(now())
employee_id Int employee_id Int
is_approved Boolean is_approved Boolean
archive_at DateTime @default(now())
@@map("timesheets_archive") @@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 { model Shifts {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id]) timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id])
timesheet_id Int timesheet_id Int
bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int bank_code_id Int
description String?
date DateTime @db.Date date DateTime @db.Date
start_time DateTime @db.Time(0) start_time DateTime @db.Time(0)
end_time DateTime @db.Time(0) end_time DateTime @db.Time(0)
is_approved Boolean @default(false) is_approved Boolean @default(false)
is_remote Boolean @default(false)
comment String?
archive ShiftsArchive[] @relation("ShiftsToArchive") archive ShiftsArchive[] @relation("ShiftsToArchive")
@ -187,16 +248,17 @@ model Shifts {
} }
model ShiftsArchive { model ShiftsArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id])
shift_id Int shift_id Int
archive_at DateTime @default(now())
timesheet_id Int
bank_code_id Int
description String?
date DateTime @db.Date date DateTime @db.Date
start_time DateTime @db.Time(0) start_time DateTime @db.Time(0)
end_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") @@map("shifts_archive")
} }
@ -208,25 +270,29 @@ model BankCodes {
modifier Float modifier Float
bank_code String bank_code String
shifts Shifts[] @relation("ShiftBankCodes") shifts Shifts[] @relation("ShiftBankCodes")
expenses Expenses[] @relation("ExpenseBankCodes") expenses Expenses[] @relation("ExpenseBankCodes")
leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes")
SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes")
@@map("bank_codes") @@map("bank_codes")
} }
model Expenses { model Expenses {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id])
timesheet_id Int 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 bank_code_id Int
date DateTime @db.Date attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull)
amount Decimal @db.Money attachment Int?
attachement String?
description String? date DateTime @db.Date
is_approved Boolean @default(false) amount Decimal @db.Money
mileage Decimal? @db.Decimal(12,2)
comment String
supervisor_comment String? supervisor_comment String?
is_approved Boolean @default(false)
archive ExpensesArchive[] @relation("ExpensesToArchive") archive ExpensesArchive[] @relation("ExpensesToArchive")
@ -234,16 +300,19 @@ model Expenses {
} }
model ExpensesArchive { model ExpensesArchive {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id])
expense_id Int expense_id Int
attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull)
attachment Int?
timesheet_id Int timesheet_id Int
archived_at DateTime @default(now()) archived_at DateTime @default(now())
bank_code_id Int bank_code_id Int
date DateTime @db.Date date DateTime @db.Date
amount Decimal @db.Money amount Decimal? @db.Money
attachement String? mileage Decimal? @db.Decimal(12,2)
description String? comment String?
is_approved Boolean is_approved Boolean
supervisor_comment String? supervisor_comment String?
@ -269,7 +338,7 @@ model OAuthSessions {
} }
model Blobs { model Blobs {
sha256 String @id @db.Char(64) sha256 String @id @db.Char(64)
size Int size Int
mime String mime String
storage_path String storage_path String
@ -282,16 +351,20 @@ model Blobs {
} }
model Attachments { model Attachments {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
sha256 String @db.Char(64) blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade)
blob Blobs @relation("AttachmnentBlob",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 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 original_name String
status AttachmentStatus @default(ACTIVE) status AttachmentStatus @default(ACTIVE)
retention_policy RetentionPolicy retention_policy RetentionPolicy
created_by String created_by String
created_at DateTime @default(now()) created_at DateTime @default(now())
expenses Expenses[] @relation("ExpenseAttachment")
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment") AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
@ -315,7 +388,20 @@ model AttachmentVariants {
@@map("attachment_variants") @@map("attachment_variants")
} }
enum AttachmentStatus { model Preferences {
id Int @id @default(autoincrement())
user Users @relation("UserPreferences", fields: [user_id], references: [id])
user_id String @unique @db.Uuid
notifications Int @default(0)
dark_mode Int @default(0)
lang_switch Int @default(0)
lefty_mode Int @default(0)
@@map("preferences")
}
enum AttachmentStatus {
ACTIVE ACTIVE
DELETED DELETED
} }
@ -347,7 +433,8 @@ enum LeaveTypes {
PARENTAL // maternite/paternite/adoption PARENTAL // maternite/paternite/adoption
LEGAL // obligations legales comme devoir de juree LEGAL // obligations legales comme devoir de juree
WEDDING // mariage WEDDING // mariage
HOLIDAY // férier
@@map("leave_types") @@map("leave_types")
} }
@ -360,3 +447,13 @@ enum LeaveApprovalStatus {
@@map("leave_approval_status") @@map("leave_approval_status")
} }
enum Weekday {
SUN
MON
TUE
WED
THU
FRI
SAT
}

View File

@ -1,27 +1,32 @@
import { Module } from '@nestjs/common'; import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { ArchivalModule } from './modules/archival/archival.module'; import { ArchivalModule } from './modules/archival/archival.module';
import { AuthenticationModule } from './modules/authentication/auth.module'; import { AuthenticationModule } from './modules/authentication/auth.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module'; import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
import { CsvExportModule } from './modules/exports/csv-exports.module'; // import { CsvExportModule } from './modules/exports/csv-exports.module';
import { CustomersModule } from './modules/customers/customers.module'; import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module'; import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module'; import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HealthController } from './health/health.controller'; import { HealthController } from './health/health.controller';
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module'; import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
import { NotificationsModule } from './modules/notifications/notifications.module'; import { NotificationsModule } from './modules/notifications/notifications.module';
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
import { PrismaModule } from './prisma/prisma.module'; import { PreferencesModule } from './modules/preferences/preferences.module';
import { ScheduleModule } from '@nestjs/schedule'; import { PrismaModule } from './prisma/prisma.module';
import { ShiftsModule } from './modules/shifts/shifts.module'; import { ScheduleModule } from '@nestjs/schedule';
import { ShiftsModule } from './modules/shifts/shifts.module';
import { TimesheetsModule } from './modules/timesheets/timesheets.module'; import { TimesheetsModule } from './modules/timesheets/timesheets.module';
import { UsersModule } from './modules/users-management/users.module'; import { UsersModule } from './modules/users-management/users.module';
import { ConfigModule } from '@nestjs/config'; 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({ @Module({
imports: [ imports: [
@ -30,7 +35,7 @@ import { ConfigModule } from '@nestjs/config';
BankCodesModule, BankCodesModule,
BusinessLogicsModule, BusinessLogicsModule,
ConfigModule.forRoot({isGlobal: true}), ConfigModule.forRoot({isGlobal: true}),
CsvExportModule, // CsvExportModule,
CustomersModule, CustomersModule,
EmployeesModule, EmployeesModule,
ExpensesModule, ExpensesModule,
@ -39,13 +44,38 @@ import { ConfigModule } from '@nestjs/config';
NotificationsModule, NotificationsModule,
OauthSessionsModule, OauthSessionsModule,
PayperiodsModule, PayperiodsModule,
PreferencesModule,
PrismaModule, PrismaModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(), //cronjobs
SchedulePresetsModule,
ShiftsModule, ShiftsModule,
TimesheetsModule, TimesheetsModule,
UsersModule, UsersModule,
], ],
controllers: [AppController, HealthController], controllers: [AppController, HealthController],
providers: [AppService, OvertimeService], providers: [
AppService,
OvertimeService,
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors: ValidationError[] = [])=> {
const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {}));
return new BadRequestException({
statusCode: 400,
error: 'Bad Request',
message: messages.length ? messages : errors,
});
},
}),
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -0,0 +1,24 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const http_context = host.switchToHttp();
const response = http_context.getResponse<Response>();
const request = http_context.getRequest<Request>();
const http_status = exception.getStatus();
const exception_response = exception.getResponse();
const normalized = typeof exception_response === 'string'
? { message: exception_response }
: (exception_response as Record<string, unknown>);
const response_body = {
statusCode: http_status,
timestamp: new Date().toISOString(),
path: request.url,
...normalized,
};
response.status(http_status).json(response_body);
}
}

View File

@ -49,6 +49,13 @@ export function getYearStart(date:Date): Date {
return new Date(date.getFullYear(),0,1,0,0,0,0); return new Date(date.getFullYear(),0,1,0,0,0,0);
} }
export function getCurrentWeek(): { start_date_week: Date; end_date_week: Date } {
const now = new Date();
const start_date_week = getWeekStart(now, 0);
const end_date_week = getWeekEnd(start_date_week);
return { start_date_week, end_date_week };
}
//cloning methods (helps with notify for overtime in a single day) //cloning methods (helps with notify for overtime in a single day)
// export function toDateOnly(day: Date): Date { // export function toDateOnly(day: Date): Date {
// const d = new Date(day); // const d = new Date(day);

View File

@ -11,7 +11,6 @@ import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed p
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; // import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
import { OwnershipGuard } from './common/guards/ownership.guard'; import { OwnershipGuard } from './common/guards/ownership.guard';
@ -25,13 +24,11 @@ async function bootstrap() {
const reflector = app.get(Reflector); //setup Reflector for Roles() const reflector = app.get(Reflector); //setup Reflector for Roles()
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
app.useGlobalGuards( app.useGlobalGuards(
// new JwtAuthGuard(reflector), //Authentification JWT // new JwtAuthGuard(reflector), //Authentification JWT
new RolesGuard(reflector), //deny-by-default and Role-based Access Control new RolesGuard(reflector), //deny-by-default and Role-based Access Control
new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet
); );
// Authentication and session // Authentication and session
app.use(session({ app.use(session({

View File

@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module";
LeaveRequestsArchiveController, LeaveRequestsArchiveController,
ShiftsArchiveController, ShiftsArchiveController,
TimesheetsArchiveController, TimesheetsArchiveController,
] ],
}) })
export class ArchivalModule {} export class ArchivalModule {}

View File

@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client'; import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client';
import { EmployeesService } from "src/modules/employees/services/employees.service"; import { EmployeesArchivalService } from "src/modules/employees/services/employees-archival.service";
@ApiTags('Employee Archives') @ApiTags('Employee Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/employees') @Controller('archives/employees')
export class EmployeesArchiveController { export class EmployeesArchiveController {
constructor(private readonly employeesService: EmployeesService) {} constructor(private readonly employeesArchiveService: EmployeesArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'List of archived employees'}) @ApiOperation({ summary: 'List of archived employees'})
@ApiResponse({ status: 200, description: 'List of archived employees', isArray: true }) @ApiResponse({ status: 200, description: 'List of archived employees', isArray: true })
async findAllArchived(): Promise<EmployeesArchive[]> { async findAllArchived(): Promise<EmployeesArchive[]> {
return this.employeesService.findAllArchived(); return this.employeesArchiveService.findAllArchived();
} }
@Get() @Get()
@ -24,7 +24,7 @@ export class EmployeesArchiveController {
@ApiResponse({ status: 200, description: 'Archived employee found'}) @ApiResponse({ status: 200, description: 'Archived employee found'})
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> { async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> {
try{ try{
return await this.employeesService.findOneArchived(id); return await this.employeesArchiveService.findOneArchived(id);
}catch { }catch {
throw new NotFoundException(`Archived employee #${id} not found`); throw new NotFoundException(`Archived employee #${id} not found`);
} }

View File

@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client"; import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
@ApiTags('Expense Archives') @ApiTags('Expense Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/expenses') @Controller('archives/expenses')
export class ExpensesArchiveController { export class ExpensesArchiveController {
constructor(private readonly expensesService: ExpensesQueryService) {} constructor(private readonly expensesService: ExpensesArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -1,33 +1,7 @@
import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common"; import { Controller } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiTags } from '@nestjs/swagger';
import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service";
@ApiTags('LeaveRequests Archives') @ApiTags('LeaveRequests Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/leaveRequests') @Controller('archives/leaveRequests')
export class LeaveRequestsArchiveController { export class LeaveRequestsArchiveController {}
constructor(private readonly leaveRequestsService: LeaveRequestsService) {}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'List of archived leaveRequests'})
@ApiResponse({ status: 200, description: 'List of archived leaveRequests', isArray: true })
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.leaveRequestsService.findAllArchived();
}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'})
@ApiResponse({ status: 200, description: 'Archived leaveRequest found'})
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<LeaveRequestViewDto> {
try{
return await this.leaveRequestsService.findOneArchived(id);
}catch {
throw new NotFoundException(`Archived leaveRequest #${id} not found`);
}
}
}

View File

@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client"; import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
@ApiTags('Shift Archives') @ApiTags('Shift Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/shifts') @Controller('archives/shifts')
export class ShiftsArchiveController { export class ShiftsArchiveController {
constructor(private readonly shiftsService:ShiftsQueryService) {} constructor(private readonly shiftsService: ShiftsArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client'; import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client';
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
@ApiTags('Timesheet Archives') @ApiTags('Timesheet Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/timesheets') @Controller('archives/timesheets')
export class TimesheetsArchiveController { export class TimesheetsArchiveController {
constructor(private readonly timesheetsService: TimesheetsQueryService) {} constructor(private readonly timesheetsService: TimesheetArchiveService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -1,19 +1,17 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service";
@Injectable() @Injectable()
export class ArchivalService { export class ArchivalService {
private readonly logger = new Logger(ArchivalService.name); private readonly logger = new Logger(ArchivalService.name);
constructor( constructor(
private readonly timesheetsService: TimesheetsQueryService, private readonly timesheetsService: TimesheetArchiveService,
private readonly expensesService: ExpensesQueryService, private readonly expensesService: ExpensesArchivalService,
private readonly shiftsService: ShiftsQueryService, private readonly shiftsService: ShiftsArchivalService,
private readonly leaveRequestsService: LeaveRequestsService,
) {} ) {}
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00
@ -31,7 +29,7 @@ export class ArchivalService {
await this.timesheetsService.archiveOld(); await this.timesheetsService.archiveOld();
await this.expensesService.archiveOld(); await this.expensesService.archiveOld();
await this.shiftsService.archiveOld(); await this.shiftsService.archiveOld();
await this.leaveRequestsService.archiveExpired(); // await this.leaveRequestsService.archiveExpired();
this.logger.log('archivation process done'); this.logger.log('archivation process done');
} catch (err) { } catch (err) {
this.logger.error('an error occured during archivation process ', err); this.logger.error('an error occured during archivation process ', err);

View File

@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse }
@Controller('bank-codes') @Controller('bank-codes')
export class BankCodesControllers { export class BankCodesControllers {
constructor(private readonly bankCodesService: BankCodesService) {} constructor(private readonly bankCodesService: BankCodesService) {}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
@Post() // @Post()
@ApiOperation({ summary: 'Create a new bank code' }) // @ApiOperation({ summary: 'Create a new bank code' })
@ApiResponse({ status: 201, description: 'Bank code successfully created.' }) // @ApiResponse({ status: 201, description: 'Bank code successfully created.' })
@ApiBadRequestResponse({ description: 'Invalid input data.' }) // @ApiBadRequestResponse({ description: 'Invalid input data.' })
create(@Body() dto: CreateBankCodeDto) { // create(@Body() dto: CreateBankCodeDto) {
return this.bankCodesService.create(dto); // return this.bankCodesService.create(dto);
} // }
@Get() // @Get()
@ApiOperation({ summary: 'Retrieve all bank codes' }) // @ApiOperation({ summary: 'Retrieve all bank codes' })
@ApiResponse({ status: 200, description: 'List of bank codes.' }) // @ApiResponse({ status: 200, description: 'List of bank codes.' })
findAll() { // findAll() {
return this.bankCodesService.findAll(); // return this.bankCodesService.findAll();
} // }
@Get(':id') // @Get(':id')
@ApiOperation({ summary: 'Retrieve a bank code by its ID' }) // @ApiOperation({ summary: 'Retrieve a bank code by its ID' })
@ApiNotFoundResponse({ description: 'Bank code not found.' }) // @ApiNotFoundResponse({ description: 'Bank code not found.' })
findOne(@Param('id', ParseIntPipe) id: number){ // findOne(@Param('id', ParseIntPipe) id: number){
return this.bankCodesService.findOne(id); // return this.bankCodesService.findOne(id);
} // }
@Patch(':id') // @Patch(':id')
@ApiOperation({ summary: 'Update an existing bank code' }) // @ApiOperation({ summary: 'Update an existing bank code' })
@ApiNotFoundResponse({ description: 'Bank code not found.' }) // @ApiNotFoundResponse({ description: 'Bank code not found.' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) { // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) {
return this.bankCodesService.update(id, dto) // return this.bankCodesService.update(id, dto)
} // }
@Delete(':id') // @Delete(':id')
@ApiOperation({ summary: 'Delete a bank code' }) // @ApiOperation({ summary: 'Delete a bank code' })
@ApiNotFoundResponse({ description: 'Bank code not found.' }) // @ApiNotFoundResponse({ description: 'Bank code not found.' })
remove(@Param('id', ParseIntPipe) id: number) { // remove(@Param('id', ParseIntPipe) id: number) {
return this.bankCodesService.remove(id); // return this.bankCodesService.remove(id);
} // }
} }

View File

@ -1,6 +1,15 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service";
import { computeHours, getWeekStart } from "src/common/utils/date-utils"; import { computeHours, getWeekStart } from "src/common/utils/date-utils";
import { PrismaService } from "../../../prisma/prisma.service";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
/*
le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier.
Un maximum de 08h00 est allouable pour le férier
Un maximum de 40hrs par semaine est retenue pour faire le calcul.
le bank-code à soumettre à Desjardins doit être le G104
*/
@Injectable() @Injectable()
export class HolidayService { export class HolidayService {
@ -8,35 +17,63 @@ export class HolidayService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
//switch employeeId for email //fetch employee_id by email
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> { private async resolveEmployeeByEmail(email: string): Promise<number> {
//sets the end of the window to 1ms before the week with the holiday const employee = await this.prisma.employees.findFirst({
const holiday_week_start = getWeekStart(holiday_date); where: {
const window_end = new Date(holiday_week_start.getTime() - 1); user: { email }
//sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday },
const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 ) select: { id: true },
});
if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`);
return employee.id;
}
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700']; private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise<number> {
//fetches all shift of the employee in said window ( 4 previous completed weeks ) const employee_id = await this.resolveEmployeeByEmail(email);
return this.computeHoursPrevious4Weeks(employee_id, holiday_date);
}
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
const holiday_week_start = getWeekStart(holiday_date);
const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS);
const window_end = new Date(holiday_week_start.getTime() - 1);
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700'];
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employee_id } , where: {
date: { gte: window_start, lte: window_end }, timesheet: { employee_id: employee_id },
bank_code: { bank_code: { in: valid_codes } }, date: { gte: window_start, lte: window_end },
bank_code: { bank_code: { in: valid_codes } },
}, },
select: { date: true, start_time: true, end_time: true }, select: { date: true, start_time: true, end_time: true },
}); });
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); const hours_by_week = new Map<number, number>();
const daily_hours = total_hours / 20; for(const shift of shifts) {
const hours = computeHours(shift.start_time, shift.end_time);
if(hours <= 0) continue;
const shift_week_start = getWeekStart(shift.date);
const key = shift_week_start.getTime();
hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours);
}
return daily_hours; let capped_total = 0;
for(let offset = 1; offset <= 4; offset++) {
const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS);
const key = week_start.getTime();
const weekly_hours = hours_by_week.get(key) ?? 0;
capped_total += Math.min(weekly_hours, 40);
}
const average_daily_hours = capped_total / 20;
return average_daily_hours;
} }
//switch employeeId for email async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise<number> {
async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise<number> { const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date);
const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date); const daily_rate = Math.min(average_daily_hours, 8);
const daily_rate = Math.min(hours, 8); this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`);
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
return daily_rate * modifier; return daily_rate * modifier;
} }
} }

View File

@ -1,55 +1,152 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class OvertimeService { export class OvertimeService {
private logger = new Logger(OvertimeService.name); private logger = new Logger(OvertimeService.name);
private daily_max = 12; // maximum for regular hours per day private daily_max = 8; // maximum for regular hours per day
private weekly_max = 80; //maximum for regular hours per week private weekly_max = 40; //maximum for regular hours per week
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
//calculate Daily overtime //calculate daily overtime
getDailyOvertimeHours(start: Date, end: Date): number { async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise<number> {
const hours = computeHours(start, end, 5); const shifts = await this.prisma.shifts.findMany({
const overtime = Math.max(0, hours - this.daily_max); where: { date: date, timesheet: { employee_id: employee_id } },
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`); select: { start_time: true, end_time: true },
return overtime; });
const total = shifts.map((shift)=>
computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0);
const overtime = Math.max(0, total - this.daily_max);
this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
return overtime;
} }
//calculate Weekly overtime //calculate Weekly overtime
//switch employeeId for email async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> { const week_start = getWeekStart(ref_date);
const week_start = getWeekStart(refDate); const week_end = getWeekEnd(week_start);
const week_end = getWeekEnd(week_start);
//fetches all shifts containing hours //fetches all shifts from INCLUDED_TYPES array
const shifts = await this.prisma.shifts.findMany({ const included_shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employeeId, shift: { where: {
every: {date: { gte: week_start, lte: week_end } } date: { gte:week_start, lte: week_end },
timesheet: { employee_id },
bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
}, },
}, select: { start_time: true, end_time: true },
}, orderBy: [{date: 'asc'}, {start_time:'asc'}],
select: { start_time: true, end_time: true },
}); });
//calculate total hours of those shifts minus weekly Max to find total overtime hours //calculate total hours of those shifts minus weekly Max to find total overtime hours
const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
.reduce((sum, hours)=> sum+hours, 0); .reduce((sum, hours)=> sum+hours, 0);
const overtime = Math.max(0, total - this.weekly_max); const overtime = Math.max(0, total - this.weekly_max);
this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`); this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
return overtime; return overtime;
} }
//apply modifier to overtime hours //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
calculateOvertimePay(overtime_hours: number, modifier: number): number { async transformRegularHoursToWeeklyOvertime(
const pay = overtime_hours * modifier; employee_id: number,
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); ref_date: Date,
tx?: Prisma.TransactionClient,
): Promise<void> {
//ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
const db = tx ?? this.prisma;
return pay; //calculate weekly overtime
const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
if(overtime_hours <= 0) return;
const convert_to_minutes = Math.round(overtime_hours * 60);
const [regular, overtime] = await Promise.all([
db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
]);
if(!regular || !overtime) return;
const week_start = getWeekStart(ref_date);
const week_end = getWeekEnd(week_start);
//gets all regular shifts and order them by desc
const regular_shifts_desc = await db.shifts.findMany({
where: {
date: { gte:week_start, lte: week_end },
timesheet: { employee_id },
bank_code_id: regular.id,
},
select: {
id: true,
timesheet_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
comment: true,
},
orderBy: [{date: 'desc'}, {start_time:'desc'}],
});
let remaining_minutes = convert_to_minutes;
for(const shift of regular_shifts_desc) {
if(remaining_minutes <= 0) break;
const start = shift.start_time;
const end = shift.end_time;
const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
if(duration_in_minutes === 0) continue;
if(duration_in_minutes <= remaining_minutes) {
await db.shifts.update({
where: { id: shift.id },
data: { bank_code_id: overtime.id },
});
remaining_minutes -= duration_in_minutes;
continue;
}
//sets the start_time of the new overtime shift
const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
//shorten the regular shift
await db.shifts.update({
where: { id: shift.id },
data: { end_time: new_overtime_start },
});
//creates the new overtime shift to replace the shorten regular shift
await db.shifts.create({
data: {
timesheet_id: shift.timesheet_id,
date: shift.date,
start_time: new_overtime_start,
end_time: end,
is_remote: shift.is_remote,
comment: shift.comment,
bank_code_id: overtime.id,
},
});
remaining_minutes = 0;
}
this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
} }
//apply modifier to overtime hours
// calculateOvertimePay(overtime_hours: number, modifier: number): number {
// const pay = overtime_hours * modifier;
// this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
// return pay;
// }
} }

View File

@ -1,6 +1,6 @@
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../../../prisma/prisma.service"; import { PrismaService } from "../../../prisma/prisma.service";
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
@Injectable() @Injectable()
export class SickLeaveService { export class SickLeaveService {
@ -9,28 +9,38 @@ export class SickLeaveService {
private readonly logger = new Logger(SickLeaveService.name); private readonly logger = new Logger(SickLeaveService.name);
//switch employeeId for email //switch employeeId for email
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number): async calculateSickLeavePay(
Promise<number> { employee_id: number,
reference_date: Date,
days_requested: number,
hours_per_day: number,
modifier: number,
): Promise<number> {
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
return 0;
}
//sets the year to jan 1st to dec 31st //sets the year to jan 1st to dec 31st
const period_start = getYearStart(reference_date); const period_start = getYearStart(reference_date);
const period_end = reference_date; const period_end = reference_date;
//fetches all shifts of a selected employee //fetches all shifts of a selected employee
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
where: { where: {
timesheet: { employee_id: employee_id }, timesheet: { employee_id: employee_id },
date: { gte: period_start, lte: period_end}, date: { gte: period_start, lte: period_end },
}, },
select: { date: true }, select: { date: true },
}); });
//count the amount of worked days //count the amount of worked days
const worked_dates = new Set( const worked_dates = new Set(
shifts.map(shift => shift.date.toISOString().slice(0,10)) shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
); );
const days_worked = worked_dates.size; const days_worked = worked_dates.size;
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} this.logger.debug(
-> ${period_end.toDateString()}`); `Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
);
//less than 30 worked days returns 0 //less than 30 worked days returns 0
if (days_worked < 30) { if (days_worked < 30) {
@ -45,22 +55,31 @@ export class SickLeaveService {
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month //calculate each completed month, starting the 1st of the next month
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1); const first_bonus_date = new Date(
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + threshold_date.getFullYear(),
(period_end.getMonth() - first_bonus_date.getMonth()) + 1; threshold_date.getMonth() + 1,
if(months < 0) months = 0; 1,
);
let months =
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) +
1;
if (months < 0) months = 0;
acquired_days += months; acquired_days += months;
//cap of 10 days //cap of 10 days
if (acquired_days > 10) acquired_days = 10; if (acquired_days > 10) acquired_days = 10;
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()} this.logger.debug(
, bonusMonths = ${months}, acquired Days = ${acquired_days}`); `Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
);
const payable_days = Math.min(acquired_days, days_requested); const payable_days = Math.min(acquired_days, days_requested);
const raw_hours = payable_days * 8 * modifier; const raw_hours = payable_days * hours_per_day * modifier;
const rounded = roundToQuarterHour(raw_hours) const rounded = roundToQuarterHour(raw_hours);
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`); this.logger.debug(
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
);
return rounded; return rounded;
} }
} }

View File

@ -6,16 +6,8 @@ export class VacationService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(VacationService.name); private readonly logger = new Logger(VacationService.name);
/**
* Calculate the ammount allowed for vacation days. //switch employeeId for email
*
* @param employee_id employee ID
* @param startDate first day of vacation
* @param daysRequested number of days requested
* @param modifier Coefficient of hours(1)
* @returns amount of payable hours
*/
//switch employeeId for email
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> { async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
//fetch hiring date //fetch hiring date
const employee = await this.prisma.employees.findUnique({ const employee = await this.prisma.employees.findUnique({
@ -56,7 +48,7 @@ export class VacationService {
const segment_end = boundaries[i+1]; const segment_end = boundaries[i+1];
//number of days in said segment //number of days in said segment
const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day);
const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) - const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) -
(segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0); (segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0);
let alloc_days: number; let alloc_days: number;

View File

@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
export class CustomersController { export class CustomersController {
constructor(private readonly customersService: CustomersService) {} constructor(private readonly customersService: CustomersService) {}
@Post() //_____________________________________________________________________________________________
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) // Deprecated or unused methods
@ApiOperation({ summary: 'Create customer' }) //_____________________________________________________________________________________________
@ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
create(@Body() dto: CreateCustomerDto): Promise<Customers> {
return this.customersService.create(dto);
}
@Get() // @Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all customers' }) // @ApiOperation({ summary: 'Create customer' })
@ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true }) // @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'List of customers not found' }) // @ApiResponse({ status: 400, description: 'Invalid task or invalid data' })
findAll(): Promise<Customers[]> { // create(@Body() dto: CreateCustomerDto): Promise<Customers> {
return this.customersService.findAll(); // return this.customersService.create(dto);
} // }
@Get(':id') // @Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find customer' }) // @ApiOperation({ summary: 'Find all customers' })
@ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto }) // @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true })
@ApiResponse({ status: 400, description: 'Customer not found' }) // @ApiResponse({ status: 400, description: 'List of customers not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> { // findAll(): Promise<Customers[]> {
return this.customersService.findOne(id); // return this.customersService.findAll();
} // }
@Patch(':id') // @Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Update customer' }) // @ApiOperation({ summary: 'Find customer' })
@ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto }) // @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Customer not found' }) // @ApiResponse({ status: 400, description: 'Customer not found' })
update( // findOne(@Param('id', ParseIntPipe) id: number): Promise<Customers> {
@Param('id', ParseIntPipe) id: number, // return this.customersService.findOne(id);
@Body() dto: UpdateCustomerDto, // }
): Promise<Customers> {
return this.customersService.update(id, dto);
}
@Delete(':id') // @Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete customer' }) // @ApiOperation({ summary: 'Update customer' })
@ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto }) // @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto })
@ApiResponse({ status: 400, description: 'Customer not found' }) // @ApiResponse({ status: 400, description: 'Customer not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{ // update(
return this.customersService.remove(id); // @Param('id', ParseIntPipe) id: number,
} // @Body() dto: UpdateCustomerDto,
// ): Promise<Customers> {
// return this.customersService.update(id, dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete customer' })
// @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto })
// @ApiResponse({ status: 400, description: 'Customer not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Customers>{
// return this.customersService.remove(id);
// }
} }

View File

@ -55,10 +55,8 @@ export class CreateCustomerDto {
example: '8436637464', example: '8436637464',
description: 'Customer`s phone number', description: 'Customer`s phone number',
}) })
@Type(() => Number) @IsString()
@IsInt() phone_number: string;
@IsPositive()
phone_number: number;
@ApiProperty({ @ApiProperty({
example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ', example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ',

View File

@ -1,92 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateCustomerDto } from '../dtos/create-customer.dto';
import { Customers, Users } from '@prisma/client';
import { UpdateCustomerDto } from '../dtos/update-customer.dto';
@Injectable() @Injectable()
export class CustomersService { export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateCustomerDto): Promise<Customers> { //_____________________________________________________________________________________________
const { // Deprecated or unused methods
first_name, //_____________________________________________________________________________________________
last_name,
email,
phone_number,
residence,
invoice_id,
} = dto;
return this.prisma.$transaction(async (transaction) => { // constructor(private readonly prisma: PrismaService) {}
const user: Users = await transaction.users.create({
data: { // async create(dto: CreateCustomerDto): Promise<Customers> {
first_name, // const {
last_name, // first_name,
email, // last_name,
phone_number, // email,
residence, // phone_number,
}, // residence,
}); // invoice_id,
return transaction.customers.create({ // } = dto;
data: {
user_id: user.id, // return this.prisma.$transaction(async (transaction) => {
invoice_id, // const user: Users = await transaction.users.create({
}, // data: {
}); // first_name,
}); // last_name,
} // email,
// phone_number,
// residence,
// },
// });
// return transaction.customers.create({
// data: {
// user_id: user.id,
// invoice_id,
// },
// });
// });
// }
findAll(): Promise<Customers[]> { // findAll(): Promise<Customers[]> {
return this.prisma.customers.findMany({ // return this.prisma.customers.findMany({
include: { user: true }, // include: { user: true },
}) // })
} // }
async findOne(id:number): Promise<Customers> { // async findOne(id:number): Promise<Customers> {
const customer = await this.prisma.customers.findUnique({ // const customer = await this.prisma.customers.findUnique({
where: { id }, // where: { id },
include: { user: true }, // include: { user: true },
}); // });
if(!customer) throw new NotFoundException(`Customer #${id} not found`); // if(!customer) throw new NotFoundException(`Customer #${id} not found`);
return customer; // return customer;
} // }
async update(id: number,dto: UpdateCustomerDto): Promise<Customers> { // async update(id: number,dto: UpdateCustomerDto): Promise<Customers> {
const customer = await this.findOne(id); // const customer = await this.findOne(id);
const { // const {
first_name, // first_name,
last_name, // last_name,
email, // email,
phone_number, // phone_number,
residence, // residence,
invoice_id, // invoice_id,
} = dto; // } = dto;
return this.prisma.$transaction(async (transaction) => { // return this.prisma.$transaction(async (transaction) => {
await transaction.users.update({ // await transaction.users.update({
where: { id: customer.user_id }, // where: { id: customer.user_id },
data: { // data: {
...(first_name !== undefined && { first_name }), // ...(first_name !== undefined && { first_name }),
...(last_name !== undefined && { last_name }), // ...(last_name !== undefined && { last_name }),
...(email !== undefined && { email }), // ...(email !== undefined && { email }),
...(phone_number !== undefined && { phone_number }), // ...(phone_number !== undefined && { phone_number }),
...(residence !== undefined && { residence }), // ...(residence !== undefined && { residence }),
}, // },
}); // });
return transaction.customers.update({ // return transaction.customers.update({
where: { id }, // where: { id },
data: { // data: {
...(invoice_id !== undefined && { invoice_id }), // ...(invoice_id !== undefined && { invoice_id }),
}, // },
}); // });
}); // });
} // }
async remove(id: number): Promise<Customers> { // async remove(id: number): Promise<Customers> {
await this.findOne(id); // await this.findOne(id);
return this.prisma.customers.delete({ where: { id }}); // return this.prisma.customers.delete({ where: { id }});
} // }
} }

View File

@ -1,37 +1,21 @@
import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common'; import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common';
import { Employees, Roles as RoleEnum } from '@prisma/client';
import { EmployeesService } from '../services/employees.service'; import { EmployeesService } from '../services/employees.service';
import { CreateEmployeeDto } from '../dtos/create-employee.dto'; import { CreateEmployeeDto } from '../dtos/create-employee.dto';
import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
import { RolesAllowed } from '../../../common/decorators/roles.decorators'; import { RolesAllowed } from '../../../common/decorators/roles.decorators';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { EmployeeListItemDto } from '../dtos/list-employee.dto'; import { EmployeeListItemDto } from '../dtos/list-employee.dto';
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; import { EmployeesArchivalService } from '../services/employees-archival.service';
@ApiTags('Employees') @ApiTags('Employees')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
// @UseGuards() // @UseGuards()
@Controller('employees') @Controller('employees')
export class EmployeesController { export class EmployeesController {
constructor(private readonly employeesService: EmployeesService) {} constructor(
private readonly employeesService: EmployeesService,
@Post() private readonly archiveService: EmployeesArchivalService,
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) ) {}
@ApiOperation({summary: 'Create employee' })
@ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
return this.employeesService.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@ApiOperation({summary: 'Find all employees' })
@ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of employees not found' })
findAll(): Promise<Employees[]> {
return this.employeesService.findAll();
}
@Get('employee-list') @Get('employee-list')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
@ -42,34 +26,6 @@ export class EmployeesController {
return this.employeesService.findListEmployees(); return this.employeesService.findListEmployees();
} }
@Get(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
@ApiOperation({summary: 'Find employee' })
@ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
@ApiResponse({ status: 400, description: 'Employee not found' })
findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.findOne(email);
}
@Get('profile/:email')
@ApiOperation({summary: 'Find employee profile' })
@ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
@ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
@ApiResponse({ status: 400, description: 'Employee profile not found' })
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}
@Delete(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
@ApiOperation({summary: 'Delete employee' })
@ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
@ApiResponse({ status: 204, description: 'Employee deleted' })
@ApiResponse({ status: 404, description: 'Employee not found' })
remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
return this.employeesService.remove(email);
}
@Patch(':email') @Patch(':email')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@ -82,10 +38,61 @@ export class EmployeesController {
// if last_work_day is set => archive the employee // if last_work_day is set => archive the employee
// else if employee is archived and first_work_day or last_work_day = null => restore // else if employee is archived and first_work_day or last_work_day = null => restore
//otherwise => standard update //otherwise => standard update
const result = await this.employeesService.patchEmployee(email, dto); const result = await this.archiveService.patchEmployee(email, dto);
if(!result) { if(!result) {
throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`) throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`)
} }
return result; return result;
} }
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({summary: 'Create employee' })
// @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateEmployeeDto): Promise<Employees> {
// return this.employeesService.create(dto);
// }
// @Get()
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING)
// @ApiOperation({summary: 'Find all employees' })
// @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of employees not found' })
// findAll(): Promise<Employees[]> {
// return this.employeesService.findAll();
// }
// @Get(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING )
// @ApiOperation({summary: 'Find employee' })
// @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto })
// @ApiResponse({ status: 400, description: 'Employee not found' })
// findOne(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
// return this.employeesService.findOne(email);
// }
// @Get('profile/:email')
// @ApiOperation({summary: 'Find employee profile' })
// @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
// @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
// @ApiResponse({ status: 400, description: 'Employee profile not found' })
// findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
// return this.employeesService.findOneProfile(email);
// }
// @Delete(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )
// @ApiOperation({summary: 'Delete employee' })
// @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' })
// @ApiResponse({ status: 204, description: 'Employee deleted' })
// @ApiResponse({ status: 404, description: 'Employee not found' })
// remove(@Param('email', ParseIntPipe) email: string): Promise<Employees> {
// return this.employeesService.remove(email);
// }
} }

View File

@ -62,10 +62,8 @@ export class CreateEmployeeDto {
example: '82538437464', example: '82538437464',
description: 'Employee`s phone number', description: 'Employee`s phone number',
}) })
@Type(() => Number) @IsString()
@IsInt() phone_number: string;
@IsPositive()
phone_number: number;
@ApiProperty({ @ApiProperty({
example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth', example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth',

View File

@ -6,7 +6,7 @@ export class EmployeeProfileItemDto {
company_name: number | null; company_name: number | null;
job_title: string | null; job_title: string | null;
email: string | null; email: string | null;
phone_number: number; phone_number: string;
first_work_day: string; first_work_day: string;
last_work_day?: string | null; last_work_day?: string | null;
residence: string | null; residence: string | null;

View File

@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {
@IsOptional() @IsOptional()
supervisor_id?: number; supervisor_id?: number;
@Max(2147483647) @IsOptional()
phone_number: number; phone_number: string;
} }

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EmployeesController } from './controllers/employees.controller'; import { EmployeesController } from './controllers/employees.controller';
import { EmployeesService } from './services/employees.service'; import { EmployeesService } from './services/employees.service';
import { EmployeesArchivalService } from './services/employees-archival.service';
import { SharedModule } from '../shared/shared.module';
@Module({ @Module({
controllers: [EmployeesController], controllers: [EmployeesController, SharedModule],
providers: [EmployeesService], providers: [EmployeesService, EmployeesArchivalService],
exports: [EmployeesService], exports: [EmployeesService, EmployeesArchivalService],
}) })
export class EmployeesModule {} export class EmployeesModule {}

View File

@ -0,0 +1,173 @@
import { Injectable } from "@nestjs/common";
import { Employees, EmployeesArchive, Users } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpdateEmployeeDto } from "../dtos/update-employee.dto";
import { toDateOrUndefined, toDateOrNull } from "../utils/employee.utils";
@Injectable()
export class EmployeesArchivalService {
constructor(private readonly prisma: PrismaService) { }
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
// 1) Tenter sur employés actifs
const active = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
if (active) {
// Archivage : si on reçoit un last_work_day défini et que l'employé nest pas déjà terminé
if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
return this.archiveOnTermination(active, dto);
}
// Sinon, update standard (split Users/Employees)
const {
first_name,
last_name,
email: new_email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
supervisor_id,
is_supervisor,
} = dto as any;
const first_work_d = toDateOrUndefined(first_work_day);
const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
? toDateOrNull(last_work_day ?? null)
: undefined;
await this.prisma.$transaction(async (transaction) => {
if (
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
) {
await transaction.users.update({
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: new_email } : {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
},
});
}
const updated = await transaction.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
...(company_code !== undefined ? { company_code } : {}),
...(job_title !== undefined ? { job_title } : {}),
...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
...(is_supervisor !== undefined ? { is_supervisor } : {}),
...(supervisor_id !== undefined ? { supervisor_id } : {}),
},
include: { user: true },
});
return updated;
});
return this.prisma.employees.findFirst({ where: { user: { email } } });
}
const user = await this.prisma.users.findUnique({ where: { email } });
if (!user) return null;
// 2) Pas trouvé en actifs → regarder en archive (pour restauration)
const archived = await this.prisma.employeesArchive.findFirst({
where: { user_id: user.id },
include: { user: true },
});
if (archived) {
// Condition de restauration : last_work_day === null ou first_work_day fourni
const restore = dto.last_work_day === null || dto.first_work_day != null;
if (restore) {
return this.restoreEmployee(archived, dto);
}
}
// 3) Ni actif, ni archivé → 404 dans le controller
return null;
}
//transfers the employee to archive and then delete from employees table
private async archiveOnTermination(active: Employees & { user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
const last_work_d = toDateOrNull(dto.last_work_day!);
if (!last_work_d) throw new Error('invalide last_work_day for archive');
return this.prisma.$transaction(async transaction => {
//detach crew from supervisor if employee is a supervisor
await transaction.employees.updateMany({
where: { supervisor_id: active.id },
data: { supervisor_id: null },
})
const archived = await transaction.employeesArchive.create({
data: {
employee_id: active.id,
user_id: active.user_id,
first_name: active.user.first_name,
last_name: active.user.last_name,
company_code: active.company_code,
job_title: active.job_title,
first_work_day: active.first_work_day,
last_work_day: last_work_d,
supervisor_id: active.supervisor_id ?? null,
is_supervisor: active.is_supervisor,
external_payroll_id: active.external_payroll_id,
},
include: { user: true }
});
//delete from employees table
await transaction.employees.delete({ where: { id: active.id } });
//return archived employee
return archived
});
}
//transfers the employee from archive to the employees table
private async restoreEmployee(archived: EmployeesArchive & { user: Users }, dto: UpdateEmployeeDto): Promise<Employees> {
// const first_work_d = toDateOrUndefined(dto.first_work_day);
return this.prisma.$transaction(async transaction => {
//restores the archived employee into the employees table
const restored = await transaction.employees.create({
data: {
user_id: archived.user_id,
company_code: archived.company_code,
job_title: archived.job_title,
first_work_day: archived.first_work_day,
last_work_day: null,
is_supervisor: archived.is_supervisor ?? false,
external_payroll_id: archived.external_payroll_id,
},
});
//deleting archived entry by id
await transaction.employeesArchive.delete({ where: { id: archived.id } });
//return restored employee
return restored;
});
}
//fetches all archived employees
async findAllArchived(): Promise<EmployeesArchive[]> {
return this.prisma.employeesArchive.findMany();
}
//fetches an archived employee
async findOneArchived(id: number): Promise<EmployeesArchive> {
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,69 +1,11 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { CreateEmployeeDto } from '../dtos/create-employee.dto';
import { UpdateEmployeeDto } from '../dtos/update-employee.dto';
import { Employees, EmployeesArchive, Users } from '@prisma/client';
import { EmployeeListItemDto } from '../dtos/list-employee.dto'; import { EmployeeListItemDto } from '../dtos/list-employee.dto';
import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
function toDateOrNull(v?: string | null): Date | null {
if (!v) return null;
const day = new Date(v);
return isNaN(day.getTime()) ? null : day;
}
function toDateOrUndefined(v?: string | null): Date | undefined {
const day = toDateOrNull(v ?? undefined);
return day === null ? undefined : day;
}
@Injectable() @Injectable()
export class EmployeesService { export class EmployeesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) { }
async create(dto: CreateEmployeeDto): Promise<Employees> {
const {
first_name,
last_name,
email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
is_supervisor,
} = dto;
return this.prisma.$transaction(async (transaction) => {
const user: Users = await transaction.users.create({
data: {
first_name,
last_name,
email,
phone_number,
residence,
},
});
return transaction.employees.create({
data: {
user_id: user.id,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
is_supervisor,
},
});
});
}
findAll(): Promise<Employees[]> {
return this.prisma.employees.findMany({
include: { user: true },
});
}
findListEmployees(): Promise<EmployeeListItemDto[]> { findListEmployees(): Promise<EmployeeListItemDto[]> {
return this.prisma.employees.findMany({ return this.prisma.employees.findMany({
@ -71,331 +13,218 @@ export class EmployeesService {
user: { user: {
select: { select: {
first_name: true, first_name: true,
last_name: true, last_name: true,
email: true, email: true,
}, },
}, },
supervisor: { supervisor: {
select: { select: {
user: { user: {
select: { select: {
first_name: true, first_name: true,
last_name: true, last_name: true,
}, },
}, },
}, },
}, },
job_title: true, job_title: true,
company_code: true, company_code: true,
} }
}).then(rows => rows.map(r => ({ }).then(rows => rows.map(r => ({
first_name: r.user.first_name, first_name: r.user.first_name,
last_name: r.user.last_name, last_name: r.user.last_name,
employee_full_name: `${r.user.first_name} ${r.user.last_name}`, email: r.user.email,
email: r.user.email, company_name: r.company_code,
job_title: r.job_title,
employee_full_name: `${r.user.first_name} ${r.user.last_name}`,
supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null, supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null,
company_name: r.company_code, })),
job_title: r.job_title,
})),
); );
} }
async findOne(email: string): Promise<Employees> { async findOneProfile(email: string): Promise<EmployeeProfileItemDto> {
const emp = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
//add search for archived employees
if (!emp) {
throw new NotFoundException(`Employee with email: ${email} not found`);
}
return emp;
}
async findOneProfile(email:string): Promise<EmployeeProfileItemDto> {
const emp = await this.prisma.employees.findFirst({ const emp = await this.prisma.employees.findFirst({
where: { user: { email } }, where: { user: { email } },
select: { select: {
user: { user: {
select: { select: {
first_name: true, first_name: true,
last_name: true, last_name: true,
email: true, email: true,
phone_number: true, phone_number: true,
residence: true, residence: true,
}, },
}, },
supervisor: { supervisor: {
select: { select: {
user: { user: {
select: { select: {
first_name: true, first_name: true,
last_name: true, last_name: true,
}, },
}, },
}, },
}, },
job_title: true, job_title: true,
company_code: true, company_code: true,
first_work_day: true, first_work_day: true,
last_work_day: true, last_work_day: true,
} }
}); });
if (!emp) throw new NotFoundException(`Employee with email ${email} not found`); if (!emp) throw new NotFoundException(`Employee with email ${email} not found`);
return { return {
first_name: emp.user.first_name, first_name: emp.user.first_name,
last_name: emp.user.last_name, last_name: emp.user.last_name,
employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`, email: emp.user.email,
email: emp.user.email, residence: emp.user.residence,
residence: emp.user.residence, phone_number: emp.user.phone_number,
phone_number: emp.user.phone_number, company_name: emp.company_code,
supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null, job_title: emp.job_title,
company_name: emp.company_code, employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`,
job_title: emp.job_title, first_work_day: emp.first_work_day.toISOString().slice(0, 10),
first_work_day: emp.first_work_day.toISOString().slice(0,10), last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0, 10) : null,
last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0,10) : null, supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null,
}; };
} }
async update( //_____________________________________________________________________________________________
email: string, // Deprecated or unused methods
dto: UpdateEmployeeDto, //_____________________________________________________________________________________________
): Promise<Employees> {
const emp = await this.findOne(email);
const { // async create(dto: CreateEmployeeDto): Promise<Employees> {
first_name, // const {
last_name, // first_name,
phone_number, // last_name,
residence, // email,
external_payroll_id, // phone_number,
company_code, // residence,
job_title, // external_payroll_id,
first_work_day, // company_code,
last_work_day, // job_title,
is_supervisor, // first_work_day,
email: new_email, // last_work_day,
} = dto; // is_supervisor,
// } = dto;
return this.prisma.$transaction(async (transaction) => { // return this.prisma.$transaction(async (transaction) => {
if( // const user: Users = await transaction.users.create({
first_name !== undefined || // data: {
last_name !== undefined || // first_name,
new_email !== undefined || // last_name,
phone_number !== undefined || // email,
residence !== undefined // phone_number,
){ // residence,
await transaction.users.update({ // },
where: { id: emp.user_id }, // });
data: { // return transaction.employees.create({
...(first_name !== undefined && { first_name }), // data: {
...(last_name !== undefined && { last_name }), // user_id: user.id,
...(email !== undefined && { email }), // external_payroll_id,
...(phone_number !== undefined && { phone_number }), // company_code,
...(residence !== undefined && { residence }), // job_title,
}, // first_work_day,
}); // last_work_day,
} // is_supervisor,
// },
// });
// });
// }
const updated = await transaction.employees.update({ // findAll(): Promise<Employees[]> {
where: { id: emp.id }, // return this.prisma.employees.findMany({
data: { // include: { user: true },
...(external_payroll_id !== undefined && { external_payroll_id }), // });
...(company_code !== undefined && { company_code }), // }
...(first_work_day !== undefined && { first_work_day }),
...(last_work_day !== undefined && { last_work_day }), // async findOne(email: string): Promise<Employees> {
...(job_title !== undefined && { job_title }), // const emp = await this.prisma.employees.findFirst({
...(is_supervisor !== undefined && { is_supervisor }), // where: { user: { email } },
}, // include: { user: true },
}); // });
return updated;
}); // //add search for archived employees
} // if (!emp) {
// throw new NotFoundException(`Employee with email: ${email} not found`);
// }
// return emp;
// }
// async update(
// email: string,
// dto: UpdateEmployeeDto,
// ): Promise<Employees> {
// const emp = await this.findOne(email);
// const {
// first_name,
// last_name,
// phone_number,
// residence,
// external_payroll_id,
// company_code,
// job_title,
// first_work_day,
// last_work_day,
// is_supervisor,
// email: new_email,
// } = dto;
// return this.prisma.$transaction(async (transaction) => {
// if(
// first_name !== undefined ||
// last_name !== undefined ||
// new_email !== undefined ||
// phone_number !== undefined ||
// residence !== undefined
// ){
// await transaction.users.update({
// where: { id: emp.user_id },
// data: {
// ...(first_name !== undefined && { first_name }),
// ...(last_name !== undefined && { last_name }),
// ...(email !== undefined && { email }),
// ...(phone_number !== undefined && { phone_number }),
// ...(residence !== undefined && { residence }),
// },
// });
// }
// const updated = await transaction.employees.update({
// where: { id: emp.id },
// data: {
// ...(external_payroll_id !== undefined && { external_payroll_id }),
// ...(company_code !== undefined && { company_code }),
// ...(first_work_day !== undefined && { first_work_day }),
// ...(last_work_day !== undefined && { last_work_day }),
// ...(job_title !== undefined && { job_title }),
// ...(is_supervisor !== undefined && { is_supervisor }),
// },
// });
// return updated;
// });
// }
async remove(email: string): Promise<Employees> { // async remove(email: string): Promise<Employees> {
const emp = await this.findOne(email); // const emp = await this.findOne(email);
return this.prisma.$transaction(async (transaction) => { // return this.prisma.$transaction(async (transaction) => {
await transaction.employees.updateMany({ // await transaction.employees.updateMany({
where: { supervisor_id: emp.id }, // where: { supervisor_id: emp.id },
data: { supervisor_id: null }, // data: { supervisor_id: null },
}); // });
const deleted_employee = await transaction.employees.delete({ // const deleted_employee = await transaction.employees.delete({
where: {id: emp.id }, // where: {id: emp.id },
}); // });
await transaction.users.delete({ // await transaction.users.delete({
where: { id: emp.user_id }, // where: { id: emp.user_id },
}); // });
return deleted_employee; // return deleted_employee;
}); // });
} // }
//archivation functions ******************************************************
async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | EmployeesArchive | null> {
// 1) Tenter sur employés actifs
const active = await this.prisma.employees.findFirst({
where: { user: { email } },
include: { user: true },
});
if (active) {
// Archivage : si on reçoit un last_work_day défini et que l'employé nest pas déjà terminé
if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
return this.archiveOnTermination(active, dto);
}
// Sinon, update standard (split Users/Employees)
const {
first_name,
last_name,
email: new_email,
phone_number,
residence,
external_payroll_id,
company_code,
job_title,
first_work_day,
last_work_day,
supervisor_id,
is_supervisor,
} = dto as any;
const first_work_d = toDateOrUndefined(first_work_day);
const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
? toDateOrNull(last_work_day ?? null)
: undefined;
await this.prisma.$transaction(async (transaction) => {
if(
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
) {
await transaction.users.update({
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: new_email }: {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
},
});
}
const updated = await transaction.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
...(company_code !== undefined ? { company_code } : {}),
...(job_title !== undefined ? { job_title } : {}),
...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
...(is_supervisor !== undefined ? { is_supervisor } : {}),
...(supervisor_id !== undefined ? { supervisor_id } : {}),
},
include: { user: true },
});
return updated;
});
return this.prisma.employees.findFirst({ where: { user: {email} } });
}
const user = await this.prisma.users.findUnique({where: {email}});
if(!user) return null;
// 2) Pas trouvé en actifs → regarder en archive (pour restauration)
const archived = await this.prisma.employeesArchive.findFirst({
where: { user_id: user.id },
include: { user: true },
});
if (archived) {
// Condition de restauration : last_work_day === null ou first_work_day fourni
const restore = dto.last_work_day === null || dto.first_work_day != null;
if (restore) {
return this.restoreEmployee(archived, dto);
}
}
// 3) Ni actif, ni archivé → 404 dans le controller
return null;
}
//transfers the employee to archive and then delete from employees table
private async archiveOnTermination(active: Employees & {user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
const last_work_d = toDateOrNull(dto.last_work_day!);
if(!last_work_d) throw new Error('invalide last_work_day for archive');
return this.prisma.$transaction(async transaction => {
//detach crew from supervisor if employee is a supervisor
await transaction.employees.updateMany({
where: { supervisor_id: active.id },
data: { supervisor_id: null },
})
const archived = await transaction.employeesArchive.create({
data: {
employee_id: active.id,
user_id: active.user_id,
first_name: active.user.first_name,
last_name: active.user.last_name,
external_payroll_id: active.external_payroll_id,
company_code: active.company_code,
job_title: active.job_title,
first_work_day: active.first_work_day,
last_work_day: last_work_d,
supervisor_id: active.supervisor_id ?? null,
is_supervisor: active.is_supervisor,
},
include: { user: true}
});
//delete from employees table
await transaction.employees.delete({ where: { id: active.id } });
//return archived employee
return archived
});
}
//transfers the employee from archive to the employees table }
private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise<Employees> {
// const first_work_d = toDateOrUndefined(dto.first_work_day);
return this.prisma.$transaction(async transaction => {
//restores the archived employee into the employees table
const restored = await transaction.employees.create({
data: {
user_id: archived.user_id,
external_payroll_id: archived.external_payroll_id,
company_code: archived.company_code,
job_title: archived.job_title,
first_work_day: archived.first_work_day,
last_work_day: null,
is_supervisor: archived.is_supervisor ?? false,
},
});
//deleting archived entry by id
await transaction.employeesArchive.delete({ where: { id: archived.id } });
//return restored employee
return restored;
});
}
//fetches all archived employees
async findAllArchived(): Promise<EmployeesArchive[]> {
return this.prisma.employeesArchive.findMany();
}
//fetches an archived employee
async findOneArchived(id: number): Promise<EmployeesArchive> {
return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -0,0 +1,9 @@
export function toDateOrNull(v?: string | null): Date | null {
if (!v) return null;
const day = new Date(v);
return isNaN(day.getTime()) ? null : day;
}
export function toDateOrUndefined(v?: string | null): Date | undefined {
const day = toDateOrNull(v ?? undefined);
return day === null ? undefined : day;
}

View File

@ -1,13 +1,12 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
import { ExpensesQueryService } from "../services/expenses-query.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto";
import { Expenses } from "@prisma/client";
import { Roles as RoleEnum } from '.prisma/client'; import { Roles as RoleEnum } from '.prisma/client';
import { UpdateExpenseDto } from "../dtos/update-expense.dto"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service"; import { ExpensesCommandService } from "../services/expenses-command.service";
import { SearchExpensesDto } from "../dtos/search-expense.dto"; 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 { ExpensesQueryService } from "../services/expenses-query.service";
@ApiTags('Expenses') @ApiTags('Expenses')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@ -15,60 +14,82 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
@Controller('Expenses') @Controller('Expenses')
export class ExpensesController { export class ExpensesController {
constructor( constructor(
private readonly expensesService: ExpensesQueryService, private readonly query: ExpensesQueryService,
private readonly expensesApprovalService: ExpensesCommandService, private readonly command: ExpensesCommandService,
) {} ) {}
@Post() @Put('upsert/:email/:date')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) async upsert_by_date(
@ApiOperation({ summary: 'Create expense' }) @Param('email') email: string,
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) @Param('date') date: string,
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) @Body() dto: UpsertExpenseDto,
create(@Body() dto: CreateExpenseDto): Promise<Expenses> { ): Promise<UpsertExpenseResult> {
return this.expensesService.create(dto); return this.command.upsertExpensesByDate(email, date, dto);
} }
@Get() @Get('list/:email/:year/:period_no')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) async findExpenseListByPayPeriodAndEmail(
@ApiOperation({ summary: 'Find all expenses' }) @Param('email') email:string,
@ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) @Param('year') year: number,
@ApiResponse({ status: 400, description: 'List of expenses not found' }) @Param('period_no') period_no: number,
@UsePipes(new ValidationPipe({ transform: true, whitelist: true })) ): Promise<DayExpensesDto> {
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> { return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
return this.expensesService.findAll(filters);
} }
@Get(':id') //_____________________________________________________________________________________________
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // Deprecated or unused methods
@ApiOperation({ summary: 'Find expense' }) //_____________________________________________________________________________________________
@ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
return this.expensesService.findOne(id);
}
@Patch(':id') // @Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Expense shift' }) // @ApiOperation({ summary: 'Create expense' })
@ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' }) // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { // create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
return this.expensesService.update(id,dto); // return this.query.create(dto);
} // }
@Delete(':id') // @Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete expense' }) // @ApiOperation({ summary: 'Find all expenses' })
@ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
@ApiResponse({ status: 400, description: 'Expense not found' }) // @ApiResponse({ status: 400, description: 'List of expenses not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> { // @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
return this.expensesService.remove(id); // findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
} // return this.query.findAll(filters);
// }
@Patch('approval/:id') // @Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { // @ApiOperation({ summary: 'Find expense' })
return this.expensesApprovalService.updateApproval(id, isApproved); // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
} // @ApiResponse({ status: 400, description: 'Expense not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
// return this.query.findOne(id);
// }
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Expense shift' })
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
// return this.query.update(id,dto);
// }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete expense' })
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
// return this.query.remove(id);
// }
// @Patch('approval/:id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
// return this.command.updateApproval(id, isApproved);
// }
} }

View File

@ -46,7 +46,7 @@ export class CreateExpenseDto {
description:'explain`s why the expense was made' description:'explain`s why the expense was made'
}) })
@IsString() @IsString()
description?: string; comment: string;
@ApiProperty({ @ApiProperty({
example: 'DENIED, APPROUVED, PENDING, etc...', example: 'DENIED, APPROUVED, PENDING, etc...',

View File

@ -14,7 +14,7 @@ export class SearchExpensesDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
description_contains?: string; comment_contains?: string;
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()

View File

@ -0,0 +1,59 @@
import { Transform, Type } from "class-transformer";
import {
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
ValidateNested
} from "class-validator";
export class ExpensePayloadDto {
@IsString()
type!: string;
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
@IsNumber()
@Min(0)
amount?: number;
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
@IsNumber()
@Min(0)
mileage?: number;
@IsString()
@MaxLength(280)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
comment!: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return value.toString();
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
return undefined;
})
@IsString()
@Matches(/^\d+$/)
@MaxLength(255)
attachment?: string;
}
export class UpsertExpenseDto {
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
old_expense?: ExpensePayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
new_expense?: ExpensePayloadDto;
}

View File

@ -3,12 +3,21 @@ import { Module } from "@nestjs/common";
import { ExpensesQueryService } from "./services/expenses-query.service"; import { ExpensesQueryService } from "./services/expenses-query.service";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { ExpensesCommandService } from "./services/expenses-command.service"; import { ExpensesCommandService } from "./services/expenses-command.service";
import { ExpensesArchivalService } from "./services/expenses-archival.service";
import { SharedModule } from "../shared/shared.module";
@Module({ @Module({
imports: [BusinessLogicsModule], imports: [BusinessLogicsModule, SharedModule],
controllers: [ExpensesController], controllers: [ExpensesController],
providers: [ExpensesQueryService, ExpensesCommandService], providers: [
exports: [ ExpensesQueryService ], ExpensesQueryService,
ExpensesArchivalService,
ExpensesCommandService,
],
exports: [
ExpensesQueryService,
ExpensesArchivalService,
],
}) })
export class ExpensesModule {} export class ExpensesModule {}

View File

@ -0,0 +1,62 @@
import { Injectable } from "@nestjs/common";
import { ExpensesArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ExpensesArchivalService {
constructor(private readonly prisma: PrismaService){}
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expenses_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expenses_to_archive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
});
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ExpensesArchive[]> {
return this.prisma.expensesArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ExpensesArchive> {
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,13 +1,38 @@
import { Injectable } from "@nestjs/common";
import { Expenses, Prisma } from "@prisma/client";
import { Decimal } from "@prisma/client/runtime/library";
import { transcode } from "buffer";
import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service"; import { Expenses, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
import {
BadRequestException,
Injectable,
NotFoundException
} from "@nestjs/common";
import {
assertAndTrimComment,
computeAmountDecimal,
computeMileageAmount,
mapDbExpenseToDayResponse,
normalizeType,
parseAttachmentId
} from "../utils/expenses.utils";
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
@Injectable() @Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> { export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(prisma: PrismaService) { super(prisma); } constructor(
prisma: PrismaService,
private readonly bankCodesResolver: BankCodesResolver,
private readonly timesheetsResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL TX-DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() { protected get delegate() {
return this.prisma.expenses; return this.prisma.expenses;
@ -23,16 +48,203 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
); );
} }
// deprecated since batch transaction are made with timesheets //_____________________________________________________________________________________________
// async updateManyWithTx( // MASTER CRUD FUNCTION
// tx: Prisma.TransactionClient, //_____________________________________________________________________________________________
// ids: number[], readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
// isApproved: boolean, ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
// ): Promise<number> {
// const { count } = await tx.expenses.updateMany({ //validates if there is an existing expense, at least 1 old or new
// where: { id: { in: ids } }, const { old_expense, new_expense } = dto ?? {};
// data: { is_approved: isApproved }, if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
// });
// return count; //validate date format
// } const date_only = toDateOnly(date);
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
//resolve employee_id by email
const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id;
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: id,
date: date_only,
},
include: {
bank_code: {
select: {
type: true,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return rows.map((r) =>
mapDbExpenseToDayResponse({
date: r.date,
amount: r.amount ?? 0,
mileage: r.mileage ?? 0,
comment: r.comment,
is_approved: r.is_approved,
bank_code: r.bank_code,
}));
};
const normalizePayload = async (payload: {
type: string;
amount?: number;
mileage?: number;
comment: string;
attachment?: string | number;
}): Promise<{
type: string;
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}> => {
const type = normalizeType(payload.type);
const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
if (type === 'MILEAGE') {
mileage = Number(payload.mileage ?? 0);
if (!(mileage > 0)) {
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
}
const amountNumber = computeMileageAmount(mileage, modifier);
amount = new Prisma.Decimal(amountNumber);
} else {
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
throw new BadRequestException('Amount required for non-MILEAGE expense');
}
amount = new Prisma.Decimal(payload.amount);
}
if (attachment !== null) {
const attachment_row = await tx.attachments.findUnique({
where: { id: attachment },
select: { status: true },
});
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive');
}
}
return {
type,
bank_code_id,
amount,
mileage,
comment,
attachment
};
};
const findExactOld = async (norm: {
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: id,
date: date_only,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
comment: norm.comment,
attachment: norm.attachment,
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
},
select: { id: true },
});
};
let action : UpsertAction;
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if(old_expense && !new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'delete';
}
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: id,
date: date_only,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
is_approved: false,
},
});
action = 'create';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if(old_expense && new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({
where: { id: existing.id },
data: {
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
},
});
action = 'update';
}
else {
throw new BadRequestException('Invalid upsert combination');
}
const day = await loadDay();
return { action, day };
});
}
} }

View File

@ -1,148 +1,174 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto"; import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
import { Expenses, ExpensesArchive } from "@prisma/client"; import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers";
import { UpdateExpenseDto } from "../dtos/update-expense.dto"; import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types";
import { MileageService } from "src/modules/business-logics/services/mileage.service"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { SearchExpensesDto } from "../dtos/search-expense.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
@Injectable() @Injectable()
export class ExpensesQueryService { export class ExpensesQueryService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mileageService: MileageService, private readonly employeeRepo: EmailToIdResolver,
) {} ) {}
async create(dto: CreateExpenseDto): Promise<Expenses> {
const { timesheet_id, bank_code_id, date, amount:rawAmount,
description, is_approved,supervisor_comment} = dto;
//fetches type and modifier //fetchs all expenses for a selected employee using email, pay-period-year and number
const bank_code = await this.prisma.bankCodes.findUnique({ async findExpenseListByPayPeriodAndEmail(
where: { id: bank_code_id }, email: string,
select: { type: true, modifier: true }, year: number,
}); period_no: number
if(!bank_code) { ): Promise<ExpenseListResponseDto> {
throw new NotFoundException(`bank_code #${bank_code_id} not found`) //fetch employe_id using email
} const employee_id = await this.employeeRepo.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
//if mileage -> service, otherwise the ratio is amount:1 //fetch pay-period using year and period_no
let final_amount: number; const pay_period = await this.prisma.payPeriods.findFirst({
if(bank_code.type === 'mileage') { where: {
final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); pay_year: year,
}else { pay_period_no: period_no
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
}
return this.prisma.expenses.create({
data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment},
include: { timesheet: { include: { employee: { include: { user: true }}}},
bank_code: true,
}, },
}) select: { period_start: true, period_end: true },
} });
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
async findAll(filters: SearchExpensesDto): Promise<Expenses[]> { const start = toUTCDateOnly(pay_period.period_start);
const where = buildPrismaWhere(filters); const end = toUTCDateOnly(pay_period.period_end);
const expenses = await this.prisma.expenses.findMany({ where })
return expenses;
}
async findOne(id: number): Promise<Expenses> { //sets rows data
const expense = await this.prisma.expenses.findUnique({ const rows = await this.prisma.expenses.findMany({
where: { id }, where: {
include: { timesheet: { include: { employee: { include: { user:true } } } }, date: { gte: start, lte: end },
bank_code: true, timesheet: { is: { employee_id } },
},
orderBy: { date: 'asc'},
select: {
amount: true,
mileage: true,
comment: true,
is_approved: true,
supervisor_comment: true,
bank_code: {select: { type: true } },
}, },
}); });
if (!expense) {
throw new NotFoundException(`Expense #${id} not found`); //declare return values
} const expenses: ExpenseDto[] = [];
return expense; let total_amount = 0;
} let total_mileage = 0;
async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> { //set rows
await this.findOne(id); for(const row of rows) {
const { timesheet_id, bank_code_id, date, amount, const type = (row.bank_code?.type ?? '').toUpperCase();
description, is_approved, supervisor_comment} = dto; const amount = round2(Number(row.amount ?? 0));
return this.prisma.expenses.update({ const mileage = round2(Number(row.mileage ?? 0));
where: { id },
data: {
...(timesheet_id !== undefined && { timesheet_id}),
...(bank_code_id !== undefined && { bank_code_id }),
...(date !== undefined && { date }),
...(amount !== undefined && { amount }),
...(description !== undefined && { description }),
...(is_approved !== undefined && { is_approved }),
...(supervisor_comment !== undefined && { supervisor_comment }),
},
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
}
async remove(id: number): Promise<Expenses> { if(type === EXPENSE_TYPES.MILEAGE) {
await this.findOne(id); total_mileage += mileage;
return this.prisma.expenses.delete({ where: { id } }); } else {
} total_amount += amount;
//archivation functions ******************************************************
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expenses_to_archive.length === 0) {
return;
} }
//copies sent to archive table //fills rows array
await transaction.expensesArchive.createMany({ expenses.push({
data: expenses_to_archive.map(exp => ({ type,
expense_id: exp.id, amount,
timesheet_id: exp.timesheet_id, mileage,
bank_code_id: exp.bank_code_id, comment: row.comment ?? '',
date: exp.date, is_approved: row.is_approved ?? false,
amount: exp.amount, supervisor_comment: row.supervisor_comment ?? '',
attachement: exp.attachement,
description: exp.description,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
}); });
}
//delete from expenses table return {
await transaction.expenses.deleteMany({ expenses,
where: { id: { in: expenses_to_archive.map(exp => exp.id) } }, total_expense: round2(total_amount),
}) total_mileage: round2(total_mileage),
};
}
}) //_____________________________________________________________________________________________
} // Deprecated or unused methods
//_____________________________________________________________________________________________
// async create(dto: CreateExpenseDto): Promise<Expenses> {
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
// comment, is_approved,supervisor_comment} = dto;
// //fetches type and modifier
// const bank_code = await this.prisma.bankCodes.findUnique({
// where: { id: bank_code_id },
// select: { type: true, modifier: true },
// });
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
//fetches all archived timesheets // //if mileage -> service, otherwise the ratio is amount:1
async findAllArchived(): Promise<ExpensesArchive[]> { // let final_amount: number;
return this.prisma.expensesArchive.findMany(); // if(bank_code.type === 'mileage') {
} // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
// }else {
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
// }
// return this.prisma.expenses.create({
// data: {
// timesheet_id,
// bank_code_id,
// date,
// amount: final_amount,
// comment,
// is_approved,
// supervisor_comment
// },
// include: { timesheet: { include: { employee: { include: { user: true }}}},
// bank_code: true,
// },
// })
// }
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// const where = buildPrismaWhere(filters);
// const expenses = await this.prisma.expenses.findMany({ where })
// return expenses;
// }
// async findOne(id: number): Promise<Expenses> {
// const expense = await this.prisma.expenses.findUnique({
// where: { id },
// include: { timesheet: { include: { employee: { include: { user:true } } } },
// bank_code: true,
// },
// });
// if (!expense) {
// throw new NotFoundException(`Expense #${id} not found`);
// }
// return expense;
// }
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
// await this.findOne(id);
// const { timesheet_id, bank_code_id, date, amount,
// comment, is_approved, supervisor_comment} = dto;
// return this.prisma.expenses.update({
// where: { id },
// data: {
// ...(timesheet_id !== undefined && { timesheet_id}),
// ...(bank_code_id !== undefined && { bank_code_id }),
// ...(date !== undefined && { date }),
// ...(amount !== undefined && { amount }),
// ...(comment !== undefined && { comment }),
// ...(is_approved !== undefined && { is_approved }),
// ...(supervisor_comment !== undefined && { supervisor_comment }),
// },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// });
// }
// async remove(id: number): Promise<Expenses> {
// await this.findOne(id);
// return this.prisma.expenses.delete({ where: { id } });
// }
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ExpensesArchive> {
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
}
} }

View File

@ -0,0 +1,14 @@
export type UpsertAction = 'create' | 'update' | 'delete';
export interface ExpenseResponse {
date: string;
type: string;
amount: number;
comment: string;
is_approved: boolean;
};
export type UpsertExpenseResult = {
action: UpsertAction;
day: ExpenseResponse[]
};

View File

@ -0,0 +1,111 @@
import { BadRequestException } from "@nestjs/common";
import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
import { Prisma } from "@prisma/client";
//uppercase and trim for validation
export function normalizeType(type: string): string {
return (type ?? '').trim().toUpperCase();
};
//required comment after trim
export function assertAndTrimComment(comment: string): string {
const cmt = (comment ?? '').trim();
if(cmt.length === 0) {
throw new BadRequestException('A comment is required');
}
return cmt;
};
//rounding $ to 2 decimals
export function roundMoney2(num: number): number {
return Math.round((num + Number.EPSILON) * 100)/ 100;
};
export function computeMileageAmount(km: number, modifier: number): number {
if(km < 0) throw new BadRequestException('mileage must be positive');
if(modifier < 0) throw new BadRequestException('modifier must be positive');
return roundMoney2(km * modifier);
};
//compat. types with Prisma.Decimal. work around Prisma import in utils.
export type DecimalLike =
| number
| string
| { toNumber?: () => number }
| { toString?: () => string };
//safe conversion to number
export function toNumberSafe(value: DecimalLike): number {
if(typeof value === 'number') return value;
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
return Number(
typeof (value as any)?.toString === 'function'
? (value as any).toString()
: value,
);
}
export const parseAttachmentId = (value: unknown): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException('Invalid attachment id');
}
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
const parsed = Number(trimmed);
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
return parsed;
}
throw new BadRequestException('Invalid attachment id');
};
//map of a row for DayExpenseResponse
export function mapDbExpenseToDayResponse(row: {
date: Date;
amount: Prisma.Decimal | number | string | null;
mileage?: Prisma.Decimal | number | string | null;
comment: string;
is_approved: boolean;
bank_code?: { type?: string | null } | null;
}): ExpenseResponse {
const yyyyMmDd = row.date.toISOString().slice(0,10);
const toNum = (value: any)=> (value == null ? 0 : Number(value));
return {
date: yyyyMmDd,
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
amount: toNum(row.amount),
comment: row.comment,
is_approved: row.is_approved,
...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
};
}
export const computeAmountDecimal = (
type: string,
payload: {
amount?: number;
mileage?: number;
},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};

View File

@ -2,8 +2,9 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common";
import { RolesGuard } from "src/common/guards/roles.guard"; import { RolesGuard } from "src/common/guards/roles.guard";
import { Roles as RoleEnum } from '.prisma/client'; import { Roles as RoleEnum } from '.prisma/client';
import { CsvExportService } from "../services/csv-exports.service"; import { CsvExportService } from "../services/csv-exports.service";
import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; // import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto";
@Controller('exports') @Controller('exports')
@ -13,33 +14,27 @@ export class CsvExportController {
@Get('csv') @Get('csv')
@Header('Content-Type', 'text/csv; charset=utf-8') @Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Dispoition', 'attachment; filename="export.csv"') @Header('Content-Disposition', 'attachment; filename="export.csv"')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR)
async exportCsv(@Query() options: ExportCsvOptionsDto, async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise<Buffer> {
@Query('period') periodId: string ): Promise<Buffer> { const rows = await this.csvService.collectTransaction(
query.year,
//sets default values query.period_no,
const companies = options.companies && options.companies.length ? options.companies : {
[ ExportCompany.TARGO, ExportCompany.SOLUCOM]; approved: query.approved ?? true,
const types = options.type && options.type.length ? options.type : types: {
Object.values(ExportType); shifts: query.shifts ?? true,
expenses: query.expenses ?? true,
//collects all holiday: query.holiday ?? true,
const all = await this.csvService.collectTransaction(Number(periodId), companies); vacation: query.vacation ?? true,
},
//filters by type companies: {
const filtered = all.filter(r => { targo: query.targo ?? true,
switch (r.bank_code.toLocaleLowerCase()) { solucom: query.solucom ?? true,
case 'holiday' : return types.includes(ExportType.HOLIDAY); },
case 'vacation' : return types.includes(ExportType.VACATION);
case 'sick-leave': return types.includes(ExportType.SICK_LEAVE);
case 'expenses' : return types.includes(ExportType.EXPENSES);
default : return types.includes(ExportType.SHIFTS);
} }
}); );
return this.csvService.generateCsv(rows);
//generating the csv file
return this.csvService.generateCsv(filtered);
} }
} }

View File

@ -1,9 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { CsvExportController } from "./controllers/csv-exports.controller"; import { CsvExportController } from "./controllers/csv-exports.controller";
import { CsvExportService } from "./services/csv-exports.service"; import { CsvExportService } from "./services/csv-exports.service";
import { SharedModule } from "../shared/shared.module";
@Module({ @Module({
providers:[CsvExportService], providers:[CsvExportService, SharedModule],
controllers: [CsvExportController], controllers: [CsvExportController],
}) })
export class CsvExportModule {} export class CsvExportModule {}

View File

@ -1,26 +1,47 @@
import { IsArray, IsEnum, IsOptional } from "class-validator"; import { Transform } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator";
export enum ExportType { function toBoolean(v: any) {
SHIFTS = 'Quart de travail', if(typeof v === 'boolean') return v;
EXPENSES = 'Depenses', if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase());
HOLIDAY = 'Ferie', return false;
VACATION = 'Vacance',
SICK_LEAVE = 'Absence'
}
export enum ExportCompany {
TARGO = 'Targo',
SOLUCOM = 'Solucom',
} }
export class ExportCsvOptionsDto { export class ExportCsvOptionsDto {
@IsOptional()
@IsArray()
@IsEnum(ExportCompany, { each: true })
companies?: ExportCompany[];
@IsOptional() @Transform(({ value }) => parseInt(value,10))
@IsArray() @IsInt() @Min(2023)
@IsEnum(ExportType, { each: true }) year! : number;
type?: ExportType[];
@Transform(({ value }) => parseInt(value,10))
@IsInt() @Min(1) @Max(26)
period_no!: number;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
approved? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
shifts? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
expenses? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
holiday? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
vacation? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
targo? : boolean = true;
@IsOptional() @IsBoolean()
@Transform(({ value }) => toBoolean(value))
solucom? : boolean = true;
} }

View File

@ -1,6 +1,5 @@
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ExportCompany } from "../dtos/export-csv-options.dto"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
export interface CsvRow { export interface CsvRow {
company_code: number; company_code: number;
@ -14,148 +13,240 @@ export interface CsvRow {
holiday_date?: string; holiday_date?: string;
} }
type Filters = {
types: {
shifts: boolean;
expenses: boolean;
holiday: boolean;
vacation: boolean;
};
companies: {
targo: boolean;
solucom: boolean;
};
approved: boolean;
};
@Injectable() @Injectable()
export class CsvExportService { export class CsvExportService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): async collectTransaction(
Promise<CsvRow[]> { year: number,
period_no: number,
const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); filters: Filters,
approved: boolean = true
): Promise<CsvRow[]> {
//fetch period
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id }, where: { pay_year: year, pay_period_no: period_no },
select: { period_start: true, period_end: true },
}); });
if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`);
const start_date = period.period_start; const start = period.period_start;
const end_date = period.period_end; const end = period.period_end;
const approved_filter = approved ? { is_approved: true } : {}; //fetch company codes from .env
const company_codes = this.resolveCompanyCodes(filters.companies);
if(company_codes.length === 0) throw new BadRequestException('No company selected');
//fetching shifts //Flag types
const shifts = await this.prisma.shifts.findMany({ const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types;
where: { if(!want_shifts && !want_expense && !want_holiday && !want_vacation) {
date: { gte: start_date, lte: end_date }, throw new BadRequestException(' No export type selected ');
...approved_filter, }
timesheet: {
employee: { company_code: { in: company_codes} } },
},
include: {
bank_code: true,
timesheet: { include: {
employee: { include: {
user:true,
supervisor: { include: {
user:true } } } } } },
},
});
//fetching expenses const approved_filter = filters.approved? { is_approved: true } : {};
const expenses = await this.prisma.expenses.findMany({
where: { const {holiday_code, vacation_code} = this.resolveLeaveCodes();
date: { gte: start_date, lte: end_date },
...approved_filter, //Prisma queries
timesheet: { employee: { company_code: { in: company_codes} } }, const promises: Array<Promise<any[]>> = [];
if (want_shifts) {
promises.push( this.prisma.shifts.findMany({
where: {
date: { gte: start, lte: end },
...approved_filter,
bank_code: { bank_code: { notIn: [ holiday_code, vacation_code ] } },
timesheet: { employee: { company_code: { in: company_codes } } },
}, },
include: { bank_code: true, select: {
timesheet: { include: { date: true,
employee: { include: { start_time: true,
user: true, end_time: true,
supervisor: { include: { bank_code: { select: { bank_code: true } },
user:true } } } } } }, timesheet: { select: {
}, employee: { select: {
}); company_code: true,
external_payroll_id: true,
user: { select: { first_name: true, last_name: true } },
}},
}},
},
}));
} else {
promises.push(Promise.resolve([]));
}
//fetching leave requests if(want_holiday) {
const leaves = await this.prisma.leaveRequests.findMany({ promises.push( this.prisma.shifts.findMany({
where : { where: {
start_date_time: { gte: start_date, lte: end_date }, date: { gte: start, lte: end },
employee: { company_code: { in: company_codes } }, ...approved_filter,
}, bank_code: { bank_code: holiday_code },
include: { timesheet: { employee: { company_code: { in: company_codes } } },
bank_code: true, },
employee: { include: { select: {
user: true, date: true,
supervisor: { include: { start_time: true,
user: true } } } }, end_time: true,
}, bank_code: { select: { bank_code: true } },
}); timesheet: { select: {
employee: { select: {
company_code: true,
external_payroll_id: true,
user: { select: { first_name: true,last_name: true } },
} },
} },
},
}));
}else {
promises.push(Promise.resolve([]));
}
if(want_vacation) {
promises.push( this.prisma.shifts.findMany({
where: {
date: { gte: start, lte: end },
...approved_filter,
bank_code: { bank_code: vacation_code },
timesheet: { employee: { company_code: { in: company_codes } } },
},
select: {
date: true,
start_time: true,
end_time: true,
bank_code: { select: { bank_code: true } },
timesheet: { select: {
employee: { select: {
company_code: true,
external_payroll_id: true,
user: { select: { first_name: true,last_name: true } },
} },
} },
},
}));
}else {
promises.push(Promise.resolve([]));
}
if(want_expense) {
promises.push( this.prisma.expenses.findMany({
where: {
date: { gte: start, lte: end },
...approved_filter,
timesheet: { employee: { company_code: { in: company_codes } } },
},
select: {
date: true,
amount: true,
bank_code: { select: { bank_code: true } },
timesheet: { select: {
employee: { select: {
company_code: true,
external_payroll_id: true,
user: { select: { first_name: true, last_name: true } },
}},
}},
},
}));
} else {
promises.push(Promise.resolve([]));
}
//array of arrays
const [ base_shifts, holiday_shifts, vacation_shifts, expenses ] = await Promise.all(promises);
//mapping
const rows: CsvRow[] = []; const rows: CsvRow[] = [];
//Shifts Mapping const map_shifts = (shift: any, is_holiday: boolean) => {
for (const shift of shifts) { const employee = shift.timesheet.employee;
const emp = shift.timesheet.employee; const week = this.computeWeekNumber(start, shift.date);
const week_number = this.computeWeekNumber(start_date, shift.date); return {
const hours = this.computeHours(shift.start_time, shift.end_time); company_code: employee.company_code,
external_payroll_id: employee.external_payroll_id,
rows.push({ full_name: `${employee.first_name} ${ employee.last_name}`,
company_code: emp.company_code, bank_code: shift.bank_code?.bank_code ?? '',
external_payroll_id: emp.external_payroll_id, quantity_hours: this.computeHours(shift.start_time, shift.end_time),
full_name: `${emp.user.first_name} ${emp.user.last_name}`,
bank_code: shift.bank_code.bank_code,
quantity_hours: hours,
amount: undefined, amount: undefined,
week_number, week_number: week,
pay_date: this.formatDate(end_date), pay_date: this.formatDate(end),
holiday_date: undefined, holiday_date: is_holiday? this.formatDate(shift.date) : '',
}); } as CsvRow;
} };
//final mapping of all shifts based filters
//Expenses Mapping for (const shift of base_shifts) rows.push(map_shifts(shift, false));
for (const e of expenses) { for (const shift of holiday_shifts) rows.push(map_shifts(shift, true ));
const emp = e.timesheet.employee; for (const shift of vacation_shifts) rows.push(map_shifts(shift, false));
const week_number = this.computeWeekNumber(start_date, e.date);
for (const expense of expenses) {
const employee = expense.timesheet.employee;
const week = this.computeWeekNumber(start, expense.date);
rows.push({ rows.push({
company_code: emp.company_code, company_code: employee.company_code,
external_payroll_id: emp.external_payroll_id, external_payroll_id: employee.external_payroll_id,
full_name: `${emp.user.first_name} ${emp.user.last_name}`, full_name: `${employee.first_name} ${ employee.last_name}`,
bank_code: e.bank_code.bank_code, bank_code: expense.bank_code?.bank_code ?? '',
quantity_hours: undefined, quantity_hours: undefined,
amount: Number(e.amount), amount: Number(expense.amount),
week_number, week_number: week,
pay_date: this.formatDate(end_date), pay_date: this.formatDate(end),
holiday_date: undefined, holiday_date: '',
}); })
} }
//Leaves Mapping //Final mapping and sorts
for(const l of leaves) { rows.sort((a,b) => {
if(!l.bank_code) continue;
const emp = l.employee;
const start = l.start_date_time;
const end = l.end_date_time ?? start;
const week_number = this.computeWeekNumber(start_date, start);
const hours = this.computeHours(start, end);
rows.push({
company_code: emp.company_code,
external_payroll_id: emp.external_payroll_id,
full_name: `${emp.user.first_name} ${emp.user.last_name}`,
bank_code: l.bank_code.bank_code,
quantity_hours: hours,
amount: undefined,
week_number,
pay_date: this.formatDate(end_date),
holiday_date: undefined,
});
}
//Final Mapping and sorts
return rows.sort((a,b) => {
if(a.external_payroll_id !== b.external_payroll_id) { if(a.external_payroll_id !== b.external_payroll_id) {
return a.external_payroll_id - b.external_payroll_id; return a.external_payroll_id - b.external_payroll_id;
} }
if(a.bank_code !== b.bank_code) { const bk_code = String(a.bank_code).localeCompare(String(b.bank_code));
return a.bank_code.localeCompare(b.bank_code); if(bk_code !== 0) return bk_code;
} if(a.bank_code !== b.bank_code) return a.bank_code.localeCompare(b.bank_code);
return a.week_number - b.week_number; return 0;
}); });
return rows;
} }
resolveLeaveCodes(): { holiday_code: string; vacation_code: string; } {
const holiday_code = process.env.HOLIDAY_CODE?.trim();
if(!holiday_code) throw new BadRequestException('Missing Holiday bank code');
const vacation_code = process.env.VACATION_CODE?.trim();
if(!vacation_code) throw new BadRequestException('Missing Vacation bank code');
return { holiday_code, vacation_code};
}
resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }): number[] {
const out: number[] = [];
if (companies.targo) {
const code_no = parseInt(process.env.TARGO_NO ?? '', 10);
if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Targo code in env');
out.push(code_no);
}
if (companies.solucom) {
const code_no = parseInt(process.env.SOLUCOM_NO ?? '', 10);
if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Solucom code in env');
out.push(code_no);
}
return out;
}
//csv builder and "mise en page"
generateCsv(rows: CsvRow[]): Buffer { generateCsv(rows: CsvRow[]): Buffer {
const header = [ const header = [
'company_code', 'company_code',
@ -169,18 +260,23 @@ export class CsvExportService {
'holiday_date', 'holiday_date',
].join(',') + '\n'; ].join(',') + '\n';
const body = rows.map(r => [ const body = rows.map(row => {
r.company_code, const full_name = `${String(row.full_name).replace(/"/g, '""')}`;
r.external_payroll_id, const quantity_hours = (typeof row.quantity_hours === 'number') ? row.quantity_hours.toFixed(2) : '';
`${r.full_name.replace(/"/g, '""')}"`, const amount = (typeof row.amount === 'number') ? row.amount.toFixed(2) : '';
r.bank_code, return [
r.quantity_hours?.toFixed(2) ?? '', row.company_code,
r.week_number, row.external_payroll_id,
r.pay_date, full_name,
r.holiday_date ?? '', row.bank_code,
].join(',')).join('\n'); quantity_hours,
amount,
return Buffer.from('\uFEFF' + header + body, 'utf8'); row.week_number,
row.pay_date,
row.holiday_date ?? '',
].join(',');
}).join('\n');
return Buffer.from('\uFEFF' + header + body, 'utf8');
} }
@ -190,9 +286,13 @@ export class CsvExportService {
} }
private computeWeekNumber(start: Date, date: Date): number { private computeWeekNumber(start: Date, date: Date): number {
const days = Math.floor((date.getTime() - start.getTime()) / (1000*60*60*24)); const dayMS = 86400000;
const days = Math.floor((this.toUTC(date).getTime() - this.toUTC(start).getTime())/ dayMS);
return Math.floor(days / 7 ) + 1; return Math.floor(days / 7 ) + 1;
} }
toUTC(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
private formatDate(d:Date): string { private formatDate(d:Date): string {
return d.toISOString().split('T')[0]; return d.toISOString().split('T')[0];

View File

@ -1,76 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { Body, Controller, Post } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; import { LeaveRequestsService } from "../services/leave-request.service";
import { LeaveRequests } from "@prisma/client"; import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; import { LeaveTypes } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
@ApiTags('Leave Requests') @ApiTags('Leave Requests')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
// @UseGuards() // @UseGuards()
@Controller('leave-requests') @Controller('leave-requests')
export class LeaveRequestController { export class LeaveRequestController {
constructor(private readonly leaveRequetsService: LeaveRequestsService){} constructor(private readonly leave_service: LeaveRequestsService){}
@Post() @Post('upsert')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
@ApiOperation({summary: 'Create leave request' }) const { action, leave_requests } = await this.leave_service.handle(dto);
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto }) return { action, leave_requests };
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) }q
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this. leaveRequetsService.create(dto);
}
@Get() //TODO:
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) /*
@ApiOperation({summary: 'Find all leave request' }) @Get('archive')
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true }) findAllArchived(){...}
@ApiResponse({ status: 400, description: 'List of leave request not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true}))
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
return this.leaveRequetsService.findAll(filters);
}
//remove emp_id and use email
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find leave request' })
@ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.findOne(id);
}
//remove emp_id and use email
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Update leave request' })
@ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.update(id, dto);
}
//remove emp_id and use email @Get('archive/:id')
@Delete(':id') findOneArchived(id){...}
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) */
@ApiOperation({summary: 'Delete leave request' })
@ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.remove(id);
}
//remove emp_id and use email }
@Patch('approval/:id')
updateApproval( @Param('id', ParseIntPipe) id: number,
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequestViewDto> {
const approvalStatus = is_approved ?
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
}
}

View File

@ -1,56 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateLeaveRequestsDto {
@IsEmail()
email: string;
@ApiProperty({
example: 7,
description: 'ID number of a leave-request code (link with bank-codes)',
})
@Type(()=> Number)
@IsInt()
bank_code_id: number;
@ApiProperty({
example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal',
description: 'type of leave request for an accounting perception',
})
@IsEnum(LeaveTypes)
leave_type: LeaveTypes;
@ApiProperty({
example: '22/06/2463',
description: 'Leave request`s start date',
})
@IsISO8601()
start_date_time:string;
@ApiProperty({
example: '25/03/3019',
description: 'Leave request`s end date',
})
@IsOptional()
@IsISO8601()
end_date_time?: string;
@ApiProperty({
example: 'My precious',
description: 'Leave request`s comment',
})
@IsString()
@IsNotEmpty()
comment: string;
@ApiProperty({
example: 'True or False or Pending or Denied or Cancelled or Escalated',
description: 'Leave request`s approval status',
})
@IsEnum(LeaveApprovalStatus)
@IsOptional()
approval_status?: LeaveApprovalStatus;
}

View File

@ -1,13 +1,14 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
export class LeaveRequestViewDto { export class LeaveRequestViewDto {
id!: number; id: number;
leave_type!: LeaveTypes; leave_type!: LeaveTypes;
start_date_time!: string; date!: string;
end_date_time!: string | null; comment!: string;
comment!: string | null;
approval_status: LeaveApprovalStatus; approval_status: LeaveApprovalStatus;
email!: string; email!: string;
employee_full_name: string; employee_full_name!: string;
days_requested?: number; payable_hours?: number;
requested_hours?: number;
action?: 'create' | 'update' | 'delete';
} }

View File

@ -1,30 +0,0 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator";
export class SearchLeaveRequestsDto {
@IsEmail()
email: string;
@IsOptional()
@Type(()=> Number)
@IsInt()
bank_code_id?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
@IsOptional()
@IsDateString()
start_date?: string;
@IsOptional()
@IsDateString()
end_date?: string;
@IsOptional()
@IsEnum(LeaveTypes)
leave_type?: LeaveTypes;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/swagger";
import { CreateLeaveRequestsDto } from "./create-leave-request.dto";
export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){}

View File

@ -0,0 +1,51 @@
import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { LeaveRequestViewDto } from "./leave-request-view.dto";
import { Type } from "class-transformer";
//sets wich function to call
export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
export type UpsertAction = (typeof UPSERT_ACTIONS)[number];
//sets wich types to use
export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[];
export type RequestTypes = (typeof REQUEST_TYPES)[number];
//filter requests by type and action
export interface UpsertResult {
action: UpsertAction;
leave_requests: LeaveRequestViewDto[];
}
export class UpsertLeaveRequestDto {
@IsEmail()
email!: string;
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsISO8601({}, { each: true })
dates!: string[];
@IsOptional()
@IsEnum(LeaveTypes)
type!: string;
@IsIn(UPSERT_ACTIONS)
action!: UpsertAction;
@IsOptional()
@IsString()
comment?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@Max(24)
requested_hours?: number;
@IsOptional()
@IsEnum(LeaveApprovalStatus)
approval_status?: LeaveApprovalStatus
}

View File

@ -1,13 +1,29 @@
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestController } from "./controllers/leave-requests.controller"; import { LeaveRequestController } from "./controllers/leave-requests.controller";
import { LeaveRequestsService } from "./services/leave-requests.service"; import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
import { LeaveRequestsService } from "./services/leave-request.service";
import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsUtils } from "./utils/leave-request.util";
import { SharedModule } from "../shared/shared.module";
@Module({ @Module({
imports: [BusinessLogicsModule], imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
controllers: [LeaveRequestController], controllers: [LeaveRequestController],
providers: [LeaveRequestsService], providers: [
exports: [LeaveRequestsService], VacationLeaveRequestsService,
SickLeaveRequestsService,
HolidayLeaveRequestsService,
LeaveRequestsService,
PrismaService,
LeaveRequestsUtils,
],
exports: [
LeaveRequestsService,
],
}) })
export class LeaveRequestsModule {} export class LeaveRequestsModule {}

View File

@ -1,14 +1,20 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select"; import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null); const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined;
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto { export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
const isoDate = row.date?.toISOString().slice(0, 10);
if (!isoDate) {
throw new Error(`Leave request #${row.id} has no date set.`);
}
return { return {
id: row.id, id: row.id,
leave_type: row.leave_type, leave_type: row.leave_type,
start_date_time: toISO(row.start_date_time)!, date: isoDate,
end_date_time: toISO(row.end_date_time), payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment, comment: row.comment,
approval_status: row.approval_status, approval_status: row.approval_status,
email, email,

View File

@ -1,19 +1,23 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; import { Prisma } from "@prisma/client";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { LeaveRequestRow } from "../utils/leave-requests.select"; import { LeaveRequestRow } from "../utils/leave-requests.select";
function toISODateString(date:Date | null): string | null { const toNum = (value?: Prisma.Decimal | null) =>
return date ? date.toISOString().slice(0,10) : null; value !== null && value !== undefined ? Number(value) : undefined;
}
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
const iso_date = row.date?.toISOString().slice(0, 10);
if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`);
return { return {
id: row.id, id: row.id,
leave_type: row.leave_type, leave_type: row.leave_type,
start_date_time: toISODateString(row.start_date_time)!, date: iso_date,
end_date_time: toISODateString(row.end_date_time), payable_hours: toNum(row.payable_hours),
requested_hours: toNum(row.requested_hours),
comment: row.comment, comment: row.comment,
approval_status: row.approval_status, approval_status: row.approval_status,
email: row.employee.user.email, email: row.employee.user.email,
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}` employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`,
} };
} }

View File

@ -0,0 +1,78 @@
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { leaveRequestsSelect } from '../utils/leave-requests.select';
import { LeaveRequestsUtils} from '../utils/leave-request.util';
import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
@Injectable()
export class HolidayLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
const created: LeaveRequestViewDto[] = [];
for (const iso_date of dates) {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.HOLIDAY,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
}
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
requested_hours: dto.requested_hours ?? 8,
payable_hours: payable,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
}
created.push({ ...mapRowToView(row), action: 'create' });
}
return { action: 'create', leave_requests: created };
}
}

View File

@ -0,0 +1,248 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayLeaveService: HolidayLeaveRequestsService,
private readonly holidayService: HolidayService,
private readonly sickLogic: SickLeaveService,
private readonly sickLeaveService: SickLeaveRequestsService,
private readonly vacationLeaveService: VacationLeaveRequestsService,
private readonly vacationLogic: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
//handle distribution to the right service according to the selected type and action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.type) {
case LeaveTypes.HOLIDAY:
if( dto.action === 'create'){
return this.holidayLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.HOLIDAY);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.HOLIDAY);
}
case LeaveTypes.VACATION:
if( dto.action === 'create'){
return this.vacationLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.VACATION);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.VACATION);
}
case LeaveTypes.SICK:
if( dto.action === 'create'){
return this.sickLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.SICK);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.SICK);
}
default:
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
}
}
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const dates = normalizeDates(dto.dates);
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const rows = await this.prisma.leaveRequests.findMany({
where: {
employee_id: employee_id,
leave_type: type,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
}
for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date);
await this.leaveUtils.removeShift(email, employee_id, iso, type);
}
}
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
return { action: "delete", leave_requests: deleted };
}
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(type);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = Number(bank_code.modifier ?? 1);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException("Dates array must not be empty");
}
const entries = await Promise.all(
dates.map(async (iso_date) => {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: type,
date,
},
},
select: leaveRequestsSelect,
});
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
return { iso_date, date, existing };
}),
);
const updated: LeaveRequestViewDto[] = [];
if (type === LeaveTypes.SICK) {
const firstExisting = entries[0].existing;
const fallbackRequested =
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
? Number(firstExisting.requested_hours)
: 8;
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
for (const { iso_date, existing } of entries) {
const previous_status = existing.approval_status;
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
for (const { iso_date, date, existing } of entries) {
const previous_status = existing.approval_status;
const fallbackRequested =
existing.requested_hours !== null && existing.requested_hours !== undefined
? Number(existing.requested_hours)
: 8;
const requested_hours = dto.requested_hours ?? fallbackRequested;
let payable: number;
switch (type) {
case LeaveTypes.HOLIDAY:
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
break;
case LeaveTypes.VACATION: {
const days_requested = requested_hours / 8;
payable = await this.vacationLogic.calculateVacationPay(
employee_id,
date,
Math.max(0, days_requested),
modifier,
);
break;
}
default:
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
? Number(existing.payable_hours)
: requested_hours;
}
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
requested_hours,
comment: dto.comment ?? existing.comment,
payable_hours: payable,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
}

View File

@ -1,175 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly vacationService: VacationService,
private readonly sickLeaveService: SickLeaveService
) {}
//function to avoid using employee_id as identifier in the frontend.
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email} },
select: { id:true },
});
if(!employee) throw new NotFoundException(`Employee with email ${email} not found`);
return employee.id;
}
//create a leave-request without the use of employee_id
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
const employee_id = await this.resolveEmployeeIdByEmail(dto.email);
const row: LeaveRequestRow = await this.prisma.leaveRequests.create({
data: {
employee_id,
bank_code_id: dto.bank_code_id,
leave_type: dto.leave_type,
start_date_time: new Date(dto.start_date_time),
end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null,
comment: dto.comment,
approval_status: dto.approval_status ?? undefined,
},
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//fetches all leave-requests using email
async findAll(filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters;
const where: any = {};
if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) };
if (email) where.employee = { user: { email } };
if (leave_type) where.leave_type = leave_type;
if (approval_status) where.approval_status = approval_status;
if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id;
const rows= await this.prisma.leaveRequests.findMany({
where,
select: leaveRequestsSelect,
orderBy: { start_date_time: 'desc' },
});
return rows.map(mapRowToViewWithDays);
}
//fetch 1 leave-request using email
async findOne(id:number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({
where: { id },
select: leaveRequestsSelect,
});
if(!row) throw new NotFoundException(`Leave Request #${id} not found`);
return mapRowToViewWithDays(row);
}
//updates 1 leave-request using email
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const data: Record<string, any> = {};
if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email);
if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id;
if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time);
if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time);
if(dto.comment !== undefined) data.comment = dto.comment;
if(dto.approval_status !== undefined) data.approval_status = dto.approval_status;
const row: LeaveRequestRow = await this.prisma.leaveRequests.update({
where: { id },
data,
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//removes 1 leave-request using email
async remove(id:number): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({
where: { id },
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//archivation functions ******************************************************
async archiveExpired(): Promise<void> {
const now = new Date();
await this.prisma.$transaction(async transaction => {
//fetches expired leave requests
const expired = await transaction.leaveRequests.findMany({
where: { end_date_time: { lt: now } },
});
if(expired.length === 0) {
return;
}
//copy unto archive table
await transaction.leaveRequestsArchive.createMany({
data: expired.map(request => ({
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
});
//delete from leave_requests table
await transaction.leaveRequests.deleteMany({
where: { id: { in: expired.map(request => request.id ) } },
});
});
}
//fetches all archived leave-requests
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.prisma.leaveRequestsArchive.findMany();
}
//remove emp_id and use email
//fetches an archived employee
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({
where: { id },
select: leaveRequestsArchiveSelect,
});
if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`);
const emp = await this.prisma.employees.findUnique({
where: { id: row.employee_id },
select: { user: {select: { email:true,
first_name: true,
last_name: true,
}}},
});
const email = emp?.user.email ?? "";
const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : "";
return mapArchiveRowToViewWithDays(row, email, full_name);
}
}

View File

@ -0,0 +1,98 @@
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { PrismaService } from "src/prisma/prisma.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class SickLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly sickService: SickLeaveService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.SICK,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`);
}
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "",
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}

View File

@ -0,0 +1,93 @@

import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class VacationLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly vacationService: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id,
start_date,
entries.length,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.VACATION,
date,
},
},
select: { id: true },
});
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
payable_hours: payable_rounded,
requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}

View File

@ -1,32 +1,19 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper';
import { mapRowToView } from "../mappers/leave-requests.mapper"; import { mapRowToView } from '../mappers/leave-requests.mapper';
import { LeaveRequestArchiveRow } from "./leave-requests-archive.select"; import { LeaveRequestArchiveRow } from './leave-requests-archive.select';
import { LeaveRequestRow } from "./leave-requests.select"; import { LeaveRequestRow } from './leave-requests.select';
function toUTCDateOnly(date: Date): Date { /** Active (table leave_requests) : proxy to base mapper */
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
const MS_PER_DAY = 86_400_000;
function computeDaysRequested(start_date: Date, end_date?: Date | null): number {
const start = toUTCDateOnly(start_date);
const end = toUTCDateOnly(end_date ?? start_date);
const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
return Math.max(1, diff);
}
/** Active (table leave_requests) : map + days_requested */
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto { export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
const view = mapRowToView(row); return mapRowToView(row);
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
return view;
} }
/** Archive (table leave_requests_archive) : map + days_requested */ /** Archive (table leave_requests_archive) : proxy to base mapper */
export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string): export function mapArchiveRowToViewWithDays(
LeaveRequestViewDto { row: LeaveRequestArchiveRow,
const view = mapArchiveRowToView(row, email, employee_full_name!); email: string,
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); employee_full_name?: string,
return view; ): LeaveRequestViewDto {
return mapArchiveRowToView(row, email, employee_full_name!);
} }

View File

@ -0,0 +1,102 @@
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveTypes } from "@prisma/client";
@Injectable()
export class LeaveRequestsUtils {
constructor(
private readonly prisma: PrismaService,
private readonly shiftsCommand: ShiftsCommandService,
){}
async syncShift(
email: string,
employee_id: number,
date: string,
hours: number,
type: LeaveTypes,
comment?: string,
) {
if (hours <= 0) return;
const duration_minutes = Math.round(hours * 60);
if (duration_minutes > 8 * 60) {
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
}
const date_only = toDateOnly(date);
const yyyy_mm_dd = toStringFromDate(date_only);
const start_minutes = 8 * 60;
const end_minutes = start_minutes + duration_minutes;
const toHHmm = (total: number) =>
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
const existing = await this.prisma.shifts.findFirst({
where: {
date: date_only,
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
await this.shiftsCommand.upsertShiftsByDate(email, {
old_shift: existing
? {
date: yyyy_mm_dd,
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
is_approved:existing.is_approved,
comment: existing.comment ?? undefined,
}
: undefined,
new_shift: {
date: yyyy_mm_dd,
start_time: toHHmm(start_minutes),
end_time: toHHmm(end_minutes),
is_remote: existing?.is_remote ?? false,
is_approved:existing?.is_approved ?? false,
comment: comment ?? existing?.comment ?? "",
type: type,
},
});
}
async removeShift(
email: string,
employee_id: number,
iso_date: string,
type: LeaveTypes,
) {
const date_only = toDateOnly(iso_date);
const yyyy_mm_dd = toStringFromDate(date_only);
const existing = await this.prisma.shifts.findFirst({
where: {
date: date_only,
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
if (!existing) return;
await this.shiftsCommand.upsertShiftsByDate(email, {
old_shift: {
date: yyyy_mm_dd,
start_time: hhmmFromLocal(existing.start_time),
end_time: hhmmFromLocal(existing.end_time),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
is_approved:existing.is_approved,
comment: existing.comment ?? undefined,
},
});
}
}

View File

@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = {
archived_at: true, archived_at: true,
employee_id: true, employee_id: true,
leave_type: true, leave_type: true,
start_date_time: true, date: true,
end_date_time: true, payable_hours: true,
requested_hours: true,
comment: true, comment: true,
approval_status: true, approval_status: true,
} satisfies Prisma.LeaveRequestsArchiveSelect; } satisfies Prisma.LeaveRequestsArchiveSelect;
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>; export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;

View File

@ -5,8 +5,9 @@ export const leaveRequestsSelect = {
id: true, id: true,
bank_code_id: true, bank_code_id: true,
leave_type: true, leave_type: true,
start_date_time: true, date: true,
end_date_time: true, payable_hours: true,
requested_hours: true,
comment: true, comment: true,
approval_status: true, approval_status: true,
employee: { select: { employee: { select: {

View File

@ -18,13 +18,6 @@ export class PayPeriodsController {
private readonly commandService: PayPeriodsCommandService, private readonly commandService: PayPeriodsCommandService,
) {} ) {}
@Get()
@ApiOperation({ summary: 'Find all pay period' })
@ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
async findAll(): Promise<PayPeriodDto[]> {
return this.queryService.findAll();
}
@Get('current-and-all') @Get('current-and-all')
@ApiOperation({summary: 'Return current pay period and the full list'}) @ApiOperation({summary: 'Return current pay period and the full list'})
@ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'}) @ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'})
@ -95,4 +88,16 @@ export class PayPeriodsController {
): Promise<PayPeriodOverviewDto> { ): Promise<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, period_no); return this.queryService.getOverviewByYearPeriod(year, period_no);
} }
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Get()
// @ApiOperation({ summary: 'Find all pay period' })
// @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
// async findAll(): Promise<PayPeriodDto[]> {
// return this.queryService.findAll();
// }
} }

View File

@ -1,44 +1,54 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class EmployeePeriodOverviewDto { export class EmployeePeriodOverviewDto {
// @ApiProperty({ // @ApiProperty({
// example: 42, // example: 42,
// description: "Employees.id (clé primaire num.)", // description: "Employees.id (clé primaire num.)",
// }) // })
// @Allow() // @Allow()
// @IsOptional() // @IsOptional()
// employee_id: number; // employee_id: number;
email:string; email: string;
@ApiProperty({ @ApiProperty({
example: 'Alex Dupont', example: 'Alex Dupont',
description: 'Nom complet de lemployé', description: 'Nom complet de lemployé',
}) })
employee_name: string; employee_name: string;
@ApiProperty({ example: 40, description: 'pay-period`s regular hours' }) @ApiProperty({ example: 40, description: 'pay-period`s regular hours' })
regular_hours: number; regular_hours: number;
@ApiProperty({ example: 0, description: 'pay-period`s evening hours' }) @ApiProperty({ example: 0, description: 'pay-period`s other hours' })
evening_hours: number; other_hours: {
evening_hours: number;
@ApiProperty({ example: 0, description: 'pay-period`s emergency hours' }) emergency_hours: number;
emergency_hours: number;
@ApiProperty({ example: 2, description: 'pay-period`s overtime hours' }) overtime_hours: number;
overtime_hours: number;
@ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) sick_hours: number;
expenses: number;
@ApiProperty({ example: 40, description: 'pay-period total mileages (km)' }) holiday_hours: number;
mileage: number;
@ApiProperty({ vacation_hours: number;
example: true, };
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
}) total_hours: number;
is_approved: boolean;
@ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' })
expenses: number;
@ApiProperty({ example: 40, description: 'pay-period total mileages (km)' })
mileage: number;
@ApiProperty({
example: true,
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
})
is_approved: boolean;
is_remote: boolean;
} }

View File

@ -7,21 +7,24 @@ import { TimesheetsModule } from "../timesheets/timesheets.module";
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service"; import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
import { SharedModule } from "../shared/shared.module";
import { PrismaService } from "src/prisma/prisma.service";
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
@Module({ @Module({
imports: [PrismaModule, TimesheetsModule], imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
providers: [ providers: [
PayPeriodsQueryService, PayPeriodsQueryService,
PayPeriodsCommandService, PayPeriodsCommandService,
TimesheetsCommandService, TimesheetsCommandService,
ExpensesCommandService, ExpensesCommandService,
ShiftsCommandService, ShiftsCommandService,
PrismaService,
], ],
controllers: [PayPeriodsController], controllers: [PayPeriodsController],
exports: [ exports: [
PayPeriodsQueryService, PayPeriodsQueryService,
PayPeriodsCommandService, PayPeriodsCommandService,
PayPeriodsQueryService,
] ]
}) })

View File

@ -68,38 +68,4 @@ export class PayPeriodsCommandService {
}); });
return {updated}; return {updated};
} }
//function to approve a single pay-period of a single employee (deprecated)
// async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
// const period = await this.prisma.payPeriods.findFirst({
// where: { pay_year, pay_period_no: period_no},
// });
// if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
// //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
// const timesheet_ist = await this.prisma.timesheets.findMany({
// where: {
// OR: [
// { shift: {some: { date: { gte: period.period_start,
// lte: period.period_end,
// },
// }},
// },
// { expense: { some: { date: { gte: period.period_start,
// lte: period.period_end,
// },
// }},
// },
// ],
// },
// select: { id: true },
// });
// //approval of both timesheet (cascading to the approval of related shifts and expenses)
// await this.prisma.$transaction(async (transaction)=> {
// for(const {id} of timesheet_ist) {
// await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true);
// }
// })
// }
} }

View File

@ -9,347 +9,399 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
@Injectable() @Injectable()
export class PayPeriodsQueryService { export class PayPeriodsQueryService {
constructor( private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) { }
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> { async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no }, where: { pay_period_no },
orderBy: { pay_year: "desc" }, orderBy: { pay_year: "desc" },
}); });
if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`); if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`);
return this.buildOverview({ return this.buildOverview({
period_start: period.period_start, period_start: period.period_start,
period_end : period.period_end, period_end: period.period_end,
payday : period.payday, payday: period.payday,
period_no : period.pay_period_no, period_no: period.pay_period_no,
pay_year : period.pay_year, pay_year: period.pay_year,
label : period.label, label: period.label,
}); });
}
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const period = computePeriod(pay_year, period_no);
return this.buildOverview({
period_start: period.period_start,
period_end : period.period_end,
period_no : period.period_no,
pay_year : period.pay_year,
payday : period.payday,
label :period.label,
} as any);
}
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
Promise<Array<{ id: number; first_name: string; last_name: string; email: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
result.push(...frontier.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
if (!include_subtree) return result;
while (frontier.length) {
const parent_ids = frontier.map(emp => emp.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parent_ids } },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
if (next.length === 0) break;
result.push(...next.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
frontier = next;
} }
return result;
}
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> { async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const crew = await this.resolveCrew(supervisor_id, include_subtree); const period = computePeriod(pay_year, period_no);
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); return this.buildOverview({
} period_start: period.period_start,
period_end: period.period_end,
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): period_no: period.period_no,
Promise<PayPeriodOverviewDto> { pay_year: period.pay_year,
// 1) Search for the period payday: period.payday,
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); label: period.label,
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`); } as any);
}
// 2) fetch supervisor //find crew member associated with supervisor
const supervisor = await this.prisma.employees.findFirst({ private async resolveCrew(supervisor_id: number, include_subtree: boolean):
where: { user: { email: email }}, Promise<Array<{ id: number; first_name: string; last_name: string; email: string }>> {
select: { const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
id: true,
is_supervisor: true,
},
});
if (!supervisor) throw new NotFoundException('No employee record linked to current user'); let frontier = await this.prisma.employees.findMany({
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
result.push(...frontier.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
// 3)fetchs crew members if (!include_subtree) return result;
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crew_ids = crew.map(c => c.id);
// seed names map for employee without data
const seed_names = new Map<number, { name: string; email: string }>(
crew.map(crew => [
crew.id,
{ name:`${crew.first_name} ${crew.last_name}`.trim(),
email: crew.email }
]
)
);
// 4) overview build while (frontier.length) {
return this.buildOverview({ const parent_ids = frontier.map(emp => emp.id);
period_no : period.pay_period_no, const next = await this.prisma.employees.findMany({
period_start: period.period_start, where: { supervisor_id: { in: parent_ids } },
period_end : period.period_end, select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
payday : period.payday, });
pay_year : period.pay_year, if (next.length === 0) break;
label : period.label, result.push(...next.map(emp => ({
}, { filtered_employee_ids: crew_ids, seed_names }); id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
} })));
frontier = next;
}
return result;
}
private async buildOverview( //fetchs crew emails
period: { period_start: string | Date; period_end: string | Date; payday: string | Date; async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
period_no: number; pay_year: number; label: string; }, const crew = await this.resolveCrew(supervisor_id, include_subtree);
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, {name: string, email: string}>} return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
): Promise<PayPeriodOverviewDto> { }
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
const start = period.period_start instanceof Date async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
? period.period_start Promise<PayPeriodOverviewDto> {
: new Date(`${period.period_start}T00:00:00.000Z`); // 1) Search for the period
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
const end = period.period_end instanceof Date // 2) fetch supervisor
? period.period_end const supervisor = await this.prisma.employees.findFirst({
: new Date(`${period.period_end}T00:00:00.000Z`); where: { user: { email: email } },
select: {
id: true,
is_supervisor: true,
},
});
const payd = period.payday instanceof Date if (!supervisor) throw new NotFoundException('No employee record linked to current user');
? period.payday if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
: new Date (`${period.payday}T00:00:00.000Z`);
//restrictEmployeeIds = filter for shifts and expenses by employees // 3)fetchs crew members
const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } }: {}; const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crew_ids = crew.map(c => c.id);
// seed names map for employee without data
const seed_names = new Map<number, { name: string; email: string }>(
crew.map(crew => [
crew.id,
{
name: `${crew.first_name} ${crew.last_name}`.trim(),
email: crew.email
}
]
)
);
// SHIFTS (filtered by crew) // 4) overview build
const shifts = await this.prisma.shifts.findMany({ return this.buildOverview({
where: { period_no: period.pay_period_no,
date: { gte: start, lte: end }, period_start: period.period_start,
timesheet: where_employee, period_end: period.period_end,
}, payday: period.payday,
select: { pay_year: period.pay_year,
start_time: true, label: period.label,
end_time: true, //add is_approved
timesheet: { select: { }, { filtered_employee_ids: crew_ids, seed_names });
is_approved: true, }
employee: { select: {
id: true, private async buildOverview(
user: { select: { period: {
first_name: true, period_start: string | Date; period_end: string | Date; payday: string | Date;
last_name: true, period_no: number; pay_year: number; label: string;
email: true, }, //add is_approved
} }, options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, { name: string, email: string }> }
} }, ): Promise<PayPeriodOverviewDto> {
const toDateString = (d: Date) => d.toISOString().slice(0, 10);
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
const start = period.period_start instanceof Date
? period.period_start
: new Date(`${period.period_start}T00:00:00.000Z`);
const end = period.period_end instanceof Date
? period.period_end
: new Date(`${period.period_end}T00:00:00.000Z`);
const payd = period.payday instanceof Date
? period.payday
: new Date(`${period.payday}T00:00:00.000Z`);
//restrictEmployeeIds = filter for shifts and expenses by employees
const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } } : {};
// SHIFTS (filtered by crew)
const shifts = await this.prisma.shifts.findMany({
where: {
date: { gte: start, lte: end },
timesheet: where_employee,
},
select: {
start_time: true,
end_time: true,
is_remote: true,
timesheet: {
select: {
is_approved: true,
employee: {
select: {
id: true,
user: {
select: {
first_name: true,
last_name: true,
email: true,
}
},
}
},
}, },
}, },
bank_code: { select: { categorie: true } }, bank_code: { select: { categorie: true, type: true } },
}, },
});
// EXPENSES (filtered by crew)
const expenses = await this.prisma.expenses.findMany({
where: {
date: { gte: start, lte: end },
timesheet: where_employee,
},
select: {
amount: true,
timesheet: { select: {
is_approved: true,
employee: { select: {
id: true,
user: { select: {
first_name: true,
last_name: true,
email: true,
} },
} },
} },
bank_code: { select: { categorie: true, modifier: true } },
},
});
const by_employee = new Map<number, EmployeePeriodOverviewDto>();
// seed for employee without data
if (options?.seed_names) {
for (const [id, {name, email}] of options.seed_names.entries()) {
by_employee.set(id, {
email,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
}); });
}
}
const ensure = (id: number, name: string, email: string) => { // EXPENSES (filtered by crew)
if (!by_employee.has(id)) { const expenses = await this.prisma.expenses.findMany({
by_employee.set(id, { where: {
email, date: { gte: start, lte: end },
employee_name: name, timesheet: where_employee,
regular_hours: 0, },
evening_hours: 0, select: {
emergency_hours: 0, amount: true,
overtime_hours: 0, timesheet: {
expenses: 0, select: {
mileage: 0, is_approved: true,
is_approved: true, employee: {
select: {
id: true,
user: {
select: {
first_name: true,
last_name: true,
email: true,
}
},
}
},
}
},
bank_code: { select: { categorie: true, modifier: true, type: true } },
},
}); });
}
return by_employee.get(id)!;
};
for (const shift of shifts) { const by_employee = new Map<number, EmployeePeriodOverviewDto>();
const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const record = ensure(employee.id, name, employee.user.email);
const hours = computeHours(shift.start_time, shift.end_time); // seed for employee without data
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); if (options?.seed_names) {
switch (categorie) { for (const [id, { name, email }] of options.seed_names.entries()) {
case "EVENING": record.evening_hours += hours; break; by_employee.set(id, {
case "EMERGENCY": email,
case "URGENT": record.emergency_hours += hours; break; employee_name: name,
case "OVERTIME": record.overtime_hours += hours; break; regular_hours: 0,
default: record.regular_hours += hours; break; other_hours: {
} evening_hours: 0,
record.is_approved = record.is_approved && shift.timesheet.is_approved; emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
},
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
is_remote: true,
});
}
}
const ensure = (id: number, name: string, email: string) => {
if (!by_employee.has(id)) {
by_employee.set(id, {
email,
employee_name: name,
regular_hours: 0,
other_hours: {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
},
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
is_remote: true,
});
}
return by_employee.get(id)!;
};
for (const shift of shifts) {
const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const record = ensure(employee.id, name, employee.user.email);
const hours = computeHours(shift.start_time, shift.end_time);
const type = (shift.bank_code?.type ?? '').toUpperCase();
switch (type) {
case "EVENING": record.other_hours.evening_hours += hours;
record.total_hours += hours;
break;
case "EMERGENCY": record.other_hours.emergency_hours += hours;
record.total_hours += hours;
break;
case "OVERTIME": record.other_hours.overtime_hours += hours;
record.total_hours += hours;
break;
case "SICK": record.other_hours.sick_hours += hours;
record.total_hours += hours;
break;
case "HOLIDAY": record.other_hours.holiday_hours += hours;
record.total_hours += hours;
break;
case "VACATION": record.other_hours.vacation_hours += hours;
record.total_hours += hours;
break;
case "REGULAR": record.regular_hours += hours;
record.total_hours += hours;
break;
}
record.is_approved = record.is_approved && shift.timesheet.is_approved;
record.is_remote = record.is_remote || !!shift.is_remote;
}
for (const expense of expenses) {
const exp = expense.timesheet.employee;
const name = `${exp.user.first_name} ${exp.user.last_name}`.trim();
const record = ensure(exp.id, name, exp.user.email);
const amount = toMoney(expense.amount);
record.expenses += amount;
const type = (expense.bank_code?.type || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 0;
if (type === "MILEAGE" && rate > 0) {
record.mileage += Math.round((amount / rate) * 100) / 100;
}
record.is_approved = record.is_approved && expense.timesheet.is_approved;
}
const employees_overview = Array.from(by_employee.values()).sort((a, b) =>
a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }),
);
return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: toDateString(payd),
period_start: toDateString(start),
period_end: toDateString(end),
label: period.label,
employees_overview,
};
} }
for (const expense of expenses) { async getSupervisor(email: string) {
const exp = expense.timesheet.employee; return this.prisma.employees.findFirst({
const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); where: { user: { email } },
const record = ensure(exp.id, name, exp.user.email); select: { id: true, is_supervisor: true },
});
const amount = toMoney(expense.amount);
record.expenses += amount;
const categorie = (expense.bank_code?.categorie || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 0;
if (categorie === "MILEAGE" && rate > 0) {
record.mileage += amount / rate;
}
record.is_approved = record.is_approved && expense.timesheet.is_approved;
} }
const employees_overview = Array.from(by_employee.values()).sort((a, b) => async findAll(): Promise<PayPeriodDto[]> {
a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), const currentPayYear = payYearOfDate(new Date());
); return listPayYear(currentPayYear).map(period => ({
pay_period_no: period.period_no,
return { pay_year: period.pay_year,
pay_period_no: period.period_no, payday: period.payday,
pay_year: period.pay_year, period_start: period.period_start,
payday: toDateString(payd), period_end: period.period_end,
period_start: toDateString(start), label: period.label,
period_end: toDateString(end), //add is_approved
label: period.label, }));
employees_overview,
};
}
async getSupervisor(email:string) {
return this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true, is_supervisor: true },
});
}
async findAll(): Promise<PayPeriodDto[]> {
const currentPayYear = payYearOfDate(new Date());
return listPayYear(currentPayYear).map(period =>({
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
period_start: period.period_start,
period_end: period.period_end,
label: period.label,
}));
}
async findOne(period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_no },
orderBy: { pay_year: "desc" },
});
if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
return mapPayPeriodToDto(row);
}
async findCurrent(date?: string): Promise<PayPeriodDto> {
const iso_day = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(iso_day);
}
async findOneByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
});
if(row) return mapPayPeriodToDto(row);
// fallback for outside of view periods
const period = computePeriod(pay_year, period_no);
return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
} }
}
//function to cherry pick a Date to find a period async findOne(period_no: number): Promise<PayPeriodDto> {
async findByDate(date: string): Promise<PayPeriodDto> { const row = await this.prisma.payPeriods.findFirst({
const dt = new Date(date); where: { pay_period_no: period_no },
const row = await this.prisma.payPeriods.findFirst({ orderBy: { pay_year: "desc" },
where: { period_start: { lte: dt }, period_end: { gte: dt } }, });
}); if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
if(row) return mapPayPeriodToDto(row); return mapPayPeriodToDto(row);
//fallback for outwside view periods
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if(!hit) throw new NotFoundException(`No period found for ${date}`);
return {
pay_period_no: hit.period_no,
pay_year : hit.pay_year,
period_start : hit.period_start,
period_end : hit.period_end,
payday : hit.payday,
label : hit.label
} }
}
async getPeriodWindow(pay_year: number, period_no: number) { async findCurrent(date?: string): Promise<PayPeriodDto> {
return this.prisma.payPeriods.findFirst({ const iso_day = date ?? new Date().toISOString().slice(0, 10);
where: {pay_year, pay_period_no: period_no }, return this.findByDate(iso_day);
select: { period_start: true, period_end: true }, }
});
} async findOneByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
});
if (row) return mapPayPeriodToDto(row);
// fallback for outside of view periods
const period = computePeriod(pay_year, period_no);
return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
}
}
//function to cherry pick a Date to find a period
async findByDate(date: string): Promise<PayPeriodDto> {
const dt = new Date(date);
const row = await this.prisma.payPeriods.findFirst({
where: { period_start: { lte: dt }, period_end: { gte: dt } },
});
if (row) return mapPayPeriodToDto(row);
//fallback for outwside view periods
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if (!hit) throw new NotFoundException(`No period found for ${date}`);
return {
pay_period_no: hit.period_no,
pay_year: hit.pay_year,
period_start: hit.period_start,
period_end: hit.period_end,
payday: hit.payday,
label: hit.label
}
}
async getPeriodWindow(pay_year: number, period_no: number) {
return this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
select: { period_start: true, period_end: true },
});
}
} }

View File

@ -0,0 +1,14 @@
import { Body, Controller, Param, Patch } from "@nestjs/common";
import { PreferencesService } from "../services/preferences.service";
import { PreferencesDto } from "../dtos/preferences.dto";
@Controller('preferences')
export class PreferencesController {
constructor(private readonly service: PreferencesService){}
@Patch(':email')
async updatePreferences(@Param('email') email: string, @Body()payload: PreferencesDto) {
return this.service.updatePreferences(email, payload);
}
}

View File

@ -0,0 +1,16 @@
import { IsInt } from "class-validator";
export class PreferencesDto {
@IsInt()
notifications: number;
@IsInt()
dark_mode: number;
@IsInt()
lang_switch: number;
@IsInt()
lefty_mode: number;
}

View File

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { PreferencesController } from "./controllers/preferences.controller";
import { PreferencesService } from "./services/preferences.service";
import { SharedModule } from "../shared/shared.module";
@Module({
imports: [SharedModule],
controllers: [ PreferencesController ],
providers: [ PreferencesService ],
exports: [ PreferencesService ],
})
export class PreferencesModule {}

View File

@ -0,0 +1,27 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Preferences } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { PreferencesDto } from "../dtos/preferences.dto";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
@Injectable()
export class PreferencesService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver ,
){}
async updatePreferences(email: string, dto: PreferencesDto ): Promise<Preferences> {
const user_id = await this.emailResolver.resolveUserIdWithEmail(email);
return this.prisma.preferences.update({
where: { user_id },
data: {
notifications: dto.notifications,
dark_mode: dto.dark_mode,
lang_switch: dto.lang_switch,
lefty_mode: dto.lefty_mode,
},
include: { user: true },
});
}
}

View File

@ -0,0 +1,44 @@
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, 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,
){}
//used to create, update or delete a schedule preset
@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);
}
//used to show the list of available schedule presets
@Get(':email')
async findListByEmail(
@Param('email') email: string,
) {
return this.queryService.findSchedulePresetsByEmail(email);
}
//used to apply a preset to a timesheet
@Post('/apply-presets/:email')
async applyPresets(
@Param('email') email: string,
@Query('preset') preset_name: string,
@Query('start') start_date: string,
) {
if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required');
if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD');
return this.applyPresets(email, preset_name, start_date);
}
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -0,0 +1,3 @@
import { Weekday } from "@prisma/client";
export const WEEKDAY: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];

View File

@ -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 { PrismaService } from "src/prisma/prisma.service";
import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service";
import { SharedModule } from "../shared/shared.module";
@Module({
imports: [SharedModule],
controllers: [SchedulePresetsController],
providers: [
PrismaService,
SchedulePresetsCommandService,
SchedulePresetsQueryService,
SchedulePresetsApplyService,
],
exports:[
SchedulePresetsCommandService,
SchedulePresetsQueryService,
SchedulePresetsApplyService,
],
}) export class SchedulePresetsModule {}

View File

@ -0,0 +1,128 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { ApplyResult } from "../types/schedule-presets.types";
import { Prisma, Weekday } from "@prisma/client";
import { WEEKDAY } from "../mappers/schedule-presets.mappers";
@Injectable()
export class SchedulePresetsApplyService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
) {}
async applyToTimesheet(
email: string,
preset_name: string,
start_date_iso: string,
): Promise<ApplyResult> {
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`);
const timesheet = await this.prisma.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_date} },
update: {},
create: { employee_id, start_date: start_date },
select: { id: true },
});
//index shifts by weekday
const index_by_day = new Map<Weekday, typeof preset.shifts>();
for (const shift of preset.shifts) {
const list = index_by_day.get(shift.week_day) ?? [];
list.push(shift);
index_by_day.set(shift.week_day, list);
}
const addDays = (date: Date, days: number) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
const overlaps = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) =>
aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime();
let created = 0;
let skipped = 0;
await this.prisma.$transaction(async (tx) => {
for(let i = 0; i < 7; i++) {
const date = addDays(start_date, i);
const week_day = WEEKDAY[date.getUTCDay()];
const shifts = index_by_day.get(week_day) ?? [];
if(shifts.length === 0) continue;
const existing = await tx.shifts.findMany({
where: { timesheet_id: timesheet.id, date: date },
orderBy: { start_time: 'asc' },
select: {
start_time: true,
end_time: true,
bank_code_id: true,
is_remote: true,
comment: true,
},
});
const payload: Prisma.ShiftsCreateManyInput[] = [];
for(const shift of shifts) {
if(shift.end_time.getTime() <= shift.start_time.getTime()) {
throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`);
}
const conflict = existing.find((existe)=> overlaps(
shift.start_time, shift.end_time ,
existe.start_time, existe.end_time,
));
if(conflict) {
throw new ConflictException({
error_code: 'SHIFT_OVERLAP_WITH_EXISTING',
mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`,
conflict: {
existing_start: conflict.start_time.toISOString().slice(11,16),
existing_end: conflict.end_time.toISOString().slice(11,16),
},
});
}
payload.push({
timesheet_id: timesheet.id,
date: date,
start_time: shift.start_time,
end_time: shift.end_time,
is_remote: shift.is_remote,
comment: null,
bank_code_id: shift.bank_code_id,
});
}
if(payload.length) {
const response = await tx.shifts.createMany({ data: payload, skipDuplicates: true });
created += response.count;
skipped += payload.length - response.count;
}
}
});
return { timesheet_id: timesheet.id, created, skipped };
}
}

Some files were not shown because too many files have changed in this diff Show More