Compare commits
15 Commits
8e6574c0b2
...
ab1f9444f9
| Author | SHA1 | Date | |
|---|---|---|---|
| ab1f9444f9 | |||
| 7d1b760934 | |||
| cc3d675727 | |||
| de6d25b256 | |||
| c4df0e2741 | |||
| cb7fd740d4 | |||
| a9c02e86d2 | |||
| 1a758c75ae | |||
| c21bbca78e | |||
| 9e57dd7ab7 | |||
| bf85053ba4 | |||
| 34cee06952 | |||
| db33948184 | |||
| c8c645f7a6 | |||
| ec407364d9 |
33
.env
Normal file
33
.env
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Gateway
|
||||||
|
GATEWAY_PORT=8000
|
||||||
|
|
||||||
|
# Set to 'true' to use X-Mock headers for testing roles. Set to 'false' when real Keycloak is ready.
|
||||||
|
MOCK_SSO=true
|
||||||
|
|
||||||
|
# Microservice Ports
|
||||||
|
EMS_PORT=8001
|
||||||
|
AMS_PORT=8002
|
||||||
|
LMS_PORT=8003
|
||||||
|
|
||||||
|
# Internal Service URLs (Used for service-to-service HTTP calls)
|
||||||
|
# EMS_URL=http://localhost:8001
|
||||||
|
# AMS_URL=http://localhost:8002
|
||||||
|
# LMS_URL=http://localhost:8003
|
||||||
|
|
||||||
|
# Internal Service URLs (Used for service-to-service Grpc calls)
|
||||||
|
EMS_URL=localhost:8001
|
||||||
|
AMS_URL=localhost:8002
|
||||||
|
LMS_URL=localhost:8003
|
||||||
|
|
||||||
|
# Internal API Token (Prevents direct access bypassing the Gateway)
|
||||||
|
INTERNAL_SERVICE_TOKEN=super-secret-dev-token-123
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD=admin123
|
||||||
|
|
||||||
|
# Database Ports (Local Dev)
|
||||||
|
EMS_DB_PORT=3306
|
||||||
|
AMS_DB_PORT=3307
|
||||||
|
LMS_DB_PORT=3308
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Use the official Deno image (alpine for smaller size)
|
||||||
|
FROM denoland/deno:latest
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the entire monorepo into the container
|
||||||
|
# This includes deno.json, packages/core, and all services
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Cache the dependencies for all services so they start instantly
|
||||||
|
RUN deno cache services/gateway/main.ts \
|
||||||
|
services/ems/main.ts \
|
||||||
|
services/ams/main.ts \
|
||||||
|
services/lms/main.ts
|
||||||
|
|
||||||
|
# The default command (docker-compose will override this for each service)
|
||||||
|
CMD ["run", "-A", "services/gateway/main.ts"]
|
||||||
19
Dockerfile.protos
Normal file
19
Dockerfile.protos
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Dockerfile.protos
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
ARG HTTP_PROXY
|
||||||
|
ARG HTTPS_PROXY
|
||||||
|
ENV http_proxy=$HTTP_PROXY
|
||||||
|
ENV https_proxy=$HTTPS_PROXY
|
||||||
|
|
||||||
|
RUN apk add --no-cache protobuf protobuf-dev curl
|
||||||
|
RUN npm install -g ts-proto
|
||||||
|
|
||||||
|
# Install grpcurl manually (not available via apk)
|
||||||
|
RUN curl -sSL https://github.com/fullstorydev/grpcurl/releases/download/v1.9.1/grpcurl_1.9.1_linux_x86_64.tar.gz \
|
||||||
|
| tar -xz -C /usr/local/bin grpcurl
|
||||||
|
|
||||||
|
ENV http_proxy=
|
||||||
|
ENV https_proxy=
|
||||||
|
|
||||||
|
CMD ["tail", "-f", "/dev/null"]
|
||||||
66
README.md
66
README.md
@ -1,2 +1,68 @@
|
|||||||
# hrms-backend
|
# hrms-backend
|
||||||
|
***
|
||||||
|
HRMS Backend - Development Setup Guide (gRPC Microservices)
|
||||||
|
|
||||||
|
The gRPC microservices refactor is complete. We've transitioned to an API-First architecture using Protocol Buffers, completely decoupled the databases, and implemented a clean API Gateway pattern.
|
||||||
|
|
||||||
|
Here is the step-by-step guide to setting up the development environment on your local machine. I have shared the SQL dump files separately for database seeding.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
Please ensure you have the following installed:
|
||||||
|
1. **Git**
|
||||||
|
2. **Docker** (or Podman with `podman-compose`)
|
||||||
|
3. **Deno** (deno 2.9.1)
|
||||||
|
|
||||||
|
### Step 1: Clone and Configure
|
||||||
|
Clone the repository and navigate into the project folder:
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd hrms-backend
|
||||||
|
```
|
||||||
|
Make sure your `.env` file is present in the root directory. If not, create one based on the `.env.example` (I can provide the dev values if needed).
|
||||||
|
|
||||||
|
### Step 2: Start the Databases
|
||||||
|
We use isolated MySQL containers for each microservice. Start them in the background:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
*(If you are using Podman, use `podman-compose up -d`)*
|
||||||
|
|
||||||
|
### Step 3: Import the SQL Dumps
|
||||||
|
I have provided the SQL dump files separately (`hrms_ems_dump.sql`, `hrms_ams_dump.sql`, `hrms_lms_dump.sql`). Place them in the root of the project folder and run these commands to import the data into the respective containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Import EMS Database (Port 3306)
|
||||||
|
docker exec -i hrms_ems_db mysql -u root -proot hrms_ems < hrms_ems_dump.sql
|
||||||
|
|
||||||
|
# Import AMS Database (Port 3307)
|
||||||
|
docker exec -i hrms_ams_db mysql -u root -proot hrms_ams < hrms_ams_dump.sql
|
||||||
|
|
||||||
|
# Import LMS Database (Port 3308)
|
||||||
|
docker exec -i hrms_lms_db mysql -u root -proot hrms_lms < hrms_lms_dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Build the Proto Compiler (One-time setup)
|
||||||
|
Because we use API-First design, the TypeScript types are generated from `.proto` files. We have a dedicated Docker container for this so you don't need to install any compilers globally.
|
||||||
|
|
||||||
|
Build and start the compiler container:
|
||||||
|
```bash
|
||||||
|
docker-compose build proto-compiler
|
||||||
|
docker-compose up -d proto-compiler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Generate the gRPC Code
|
||||||
|
Run the provided script to generate the Deno/TypeScript gRPC clients and server interfaces:
|
||||||
|
```bash
|
||||||
|
./scripts/gen_protos.sh
|
||||||
|
```
|
||||||
|
*(You will see a `generated/` folder populated with `.ts` files).*
|
||||||
|
|
||||||
|
### Step 6: Start the Backend Services
|
||||||
|
We have a convenience script that starts the 4 Deno services (API Gateway, EMS, AMS, LMS) simultaneously for hot-reloading.
|
||||||
|
```bash
|
||||||
|
./start-dev.sh
|
||||||
|
```
|
||||||
|
*(Alternatively, you can open 4 separate terminals and run `deno run -A --env-file=.env services/<module>/main.ts` in each).*
|
||||||
|
|
||||||
|
### Step 7: Testing the APIs (Postman)
|
||||||
|
The entire backend is now hidden behind the API Gateway on `http://localhost:8000`.
|
||||||
|
|||||||
@ -1,519 +0,0 @@
|
|||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
|
|
||||||
const amsDb = getDbPool("hrms_ams");
|
|
||||||
const emsDb = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch top 20 raw biometric records for debugging/auditing
|
|
||||||
*/
|
|
||||||
export const getRawLogs = async (ctx: Context) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await amsDb.execute(
|
|
||||||
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
data: rows,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("DB Error:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, message: "Database query failed" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to generate an array of dates between two boundaries
|
|
||||||
const getDaysArray = (start: string, end: string): string[] => {
|
|
||||||
const arr: string[] = [];
|
|
||||||
const dt = new Date(start);
|
|
||||||
const endDt = new Date(end);
|
|
||||||
while (dt <= endDt) {
|
|
||||||
arr.push(new Date(dt).toISOString().split('T')[0]);
|
|
||||||
dt.setDate(dt.getDate() + 1);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processDailyAttendance = async (ctx: Context) => {
|
|
||||||
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
|
||||||
let datesToProcess: string[] = [];
|
|
||||||
|
|
||||||
// 1. Determine date execution scope
|
|
||||||
if (body.work_date) {
|
|
||||||
datesToProcess.push(body.work_date);
|
|
||||||
} else if (body.start_date && body.end_date) {
|
|
||||||
datesToProcess = getDaysArray(body.start_date, body.end_date);
|
|
||||||
} else {
|
|
||||||
// Safely pull unique dates using direct string formatting in SQL to avoid Deno timezone shifting bugs
|
|
||||||
const [dateRows]: any = await amsDb.execute(
|
|
||||||
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
|
|
||||||
);
|
|
||||||
// Explicitly guarantee we extract the exact date string format without hitting Deno Date constructor limits
|
|
||||||
datesToProcess = dateRows.map((r: any) => {
|
|
||||||
if (r.raw_date instanceof Date) {
|
|
||||||
// Fallback if the driver parses it into a Date object despite format rules
|
|
||||||
const offset = r.raw_date.getTimezoneOffset();
|
|
||||||
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
|
|
||||||
return localDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
return String(r.raw_date); // Clean 'YYYY-MM-DD' string representation
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (datesToProcess.length === 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "No dates found to process." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amsConnection = await amsDb.getConnection();
|
|
||||||
let totalProcessedRecords = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await amsConnection.beginTransaction();
|
|
||||||
|
|
||||||
// Loop through every single date sequentially
|
|
||||||
for (const targetDate of datesToProcess) {
|
|
||||||
// 2. Fetch raw logs specifically grouped for this targetDate
|
|
||||||
const [rawLogs]: any = await amsConnection.execute(
|
|
||||||
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
|
|
||||||
FROM attendance_raw_logs
|
|
||||||
WHERE DATE(attendance_time) = ?
|
|
||||||
ORDER BY employee_code, attendance_time ASC`,
|
|
||||||
[targetDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rawLogs.length === 0) continue;
|
|
||||||
|
|
||||||
// Group raw logs by employee_code for this specific day
|
|
||||||
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
|
|
||||||
if (!acc[log.employee_code]) acc[log.employee_code] = [];
|
|
||||||
acc[log.employee_code].push(log.att_time);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// 3. Process calculations per employee for this targetDate
|
|
||||||
for (const empCode in groupedLogs) {
|
|
||||||
// Look up employee metadata from EMS
|
|
||||||
const [empRows]: any = await emsDb.execute(
|
|
||||||
`SELECT employee_id, is_active FROM employees WHERE employee_code = ?`,
|
|
||||||
[empCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (empRows.length === 0 || !empRows[0].is_active) {
|
|
||||||
console.log(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const employeeId = empRows[0].employee_id;
|
|
||||||
const punches = groupedLogs[empCode];
|
|
||||||
|
|
||||||
// Fetch shift details (Defaulting to shift_id 1 for evaluation)
|
|
||||||
const [shiftRows]: any = await amsConnection.execute(
|
|
||||||
`SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
|
|
||||||
);
|
|
||||||
const shift = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
|
|
||||||
|
|
||||||
let check_in = null;
|
|
||||||
let check_out = null;
|
|
||||||
let worked_hours = 0.0;
|
|
||||||
let check_in_status = "ON_TIME";
|
|
||||||
let final_status = "ABSENT";
|
|
||||||
|
|
||||||
if (punches.length >= 2) {
|
|
||||||
check_in = punches[0];
|
|
||||||
check_out = punches[punches.length - 1];
|
|
||||||
|
|
||||||
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
|
|
||||||
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
|
|
||||||
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
|
|
||||||
|
|
||||||
const rawCheckInTimeStr = check_in.split(' ')[1];
|
|
||||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
|
||||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
|
||||||
|
|
||||||
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
|
|
||||||
check_in_status = "LATE";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
|
||||||
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
|
||||||
else final_status = "ABSENT";
|
|
||||||
|
|
||||||
} else if (punches.length === 1) {
|
|
||||||
check_in = punches[0];
|
|
||||||
check_in_status = "MISPUNCH";
|
|
||||||
final_status = "MISPUNCH";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Upsert calculations atomically into the processed daily ledger
|
|
||||||
await amsConnection.execute(
|
|
||||||
`INSERT INTO processed_daily_attendance
|
|
||||||
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
|
|
||||||
VALUES (?, 1, 1, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
check_in = VALUES(check_in),
|
|
||||||
check_out = VALUES(check_out),
|
|
||||||
worked_hours = VALUES(worked_hours),
|
|
||||||
check_in_status = VALUES(check_in_status),
|
|
||||||
final_status = VALUES(final_status)`,
|
|
||||||
[employeeId, targetDate, check_in, check_out, worked_hours, check_in_status, final_status]
|
|
||||||
);
|
|
||||||
|
|
||||||
totalProcessedRecords++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await amsConnection.commit();
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
await amsConnection.rollback();
|
|
||||||
console.error("Bulk processing failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
} finally {
|
|
||||||
amsConnection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeSummary = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const employeeId = params.get("employee_id");
|
|
||||||
const startDate = params.get("start_date");
|
|
||||||
const endDate = params.get("end_date");
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!employeeId || !startDate || !endDate) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Missing required query parameters. Expected: employee_id, start_date, and end_date."
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch the individual breakdown rows for the period
|
|
||||||
const [rows]: any = await amsDb.execute(
|
|
||||||
`SELECT
|
|
||||||
attendance_id,
|
|
||||||
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
|
||||||
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours,
|
|
||||||
check_in_status,
|
|
||||||
final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
|
||||||
ORDER BY work_date DESC`,
|
|
||||||
[employeeId, startDate, endDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dynamic metrics aggregation block
|
|
||||||
const summaryMetrics = rows.reduce(
|
|
||||||
(acc: any, row: any) => {
|
|
||||||
acc.total_days_tracked++;
|
|
||||||
acc.total_hours_worked += Number(row.worked_hours || 0);
|
|
||||||
|
|
||||||
// Count up categorical operational states
|
|
||||||
if (row.final_status === "FULL_DAY") acc.full_days++;
|
|
||||||
else if (row.final_status === "HALF_DAY") acc.half_days++;
|
|
||||||
else if (row.final_status === "ABSENT") acc.absences++;
|
|
||||||
else if (row.final_status === "MISPUNCH") acc.mispunches++;
|
|
||||||
|
|
||||||
if (row.check_in_status === "LATE") acc.late_arrivals++;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
total_days_tracked: 0,
|
|
||||||
total_hours_worked: 0,
|
|
||||||
full_days: 0,
|
|
||||||
half_days: 0,
|
|
||||||
absences: 0,
|
|
||||||
mispunches: 0,
|
|
||||||
late_arrivals: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Format final working duration to a neat 2-decimal scale
|
|
||||||
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
metrics: summaryMetrics,
|
|
||||||
history: rows
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch employee summary:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDailyReport = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const targetDate = params.get("date");
|
|
||||||
|
|
||||||
if (!targetDate) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing required 'date' query parameter (YYYY-MM-DD)." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Grab all calculated attendance states for this date from AMS
|
|
||||||
const [attendanceRows]: any = await amsDb.execute(
|
|
||||||
`SELECT employee_id, attendance_id,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
|
||||||
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE work_date = ?`,
|
|
||||||
[targetDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Fetch active corporate names and metadata mappings from EMS
|
|
||||||
const [employeeRows]: any = await emsDb.execute(
|
|
||||||
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
|
||||||
FROM employees e
|
|
||||||
INNER JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
WHERE e.is_active = TRUE`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Map the attendance records by employee_id for O(1) matching speed
|
|
||||||
const attendanceMap = attendanceRows.reduce((acc: any, row: any) => {
|
|
||||||
acc[row.employee_id] = row;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Summary metrics counter block for the Admin dashboard cards
|
|
||||||
const summary = {
|
|
||||||
total_active_workforce: employeeRows.length,
|
|
||||||
present: 0,
|
|
||||||
late: 0,
|
|
||||||
mispunches: 0,
|
|
||||||
absent: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. Seamlessly blend employee profiles with attendance states
|
|
||||||
const roster = employeeRows.map((emp: any) => {
|
|
||||||
const attendance = attendanceMap[emp.employee_id];
|
|
||||||
|
|
||||||
let status = "ABSENT";
|
|
||||||
let check_in = null;
|
|
||||||
let check_out = null;
|
|
||||||
let worked_hours = "0.00";
|
|
||||||
let punctuality = "N/A";
|
|
||||||
let attendance_id = null;
|
|
||||||
|
|
||||||
if (attendance) {
|
|
||||||
status = attendance.final_status;
|
|
||||||
check_in = attendance.check_in;
|
|
||||||
check_out = attendance.check_out;
|
|
||||||
worked_hours = attendance.worked_hours;
|
|
||||||
punctuality = attendance.check_in_status;
|
|
||||||
attendance_id = attendance.attendance_id;
|
|
||||||
|
|
||||||
// Process summary counter evaluations
|
|
||||||
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
|
|
||||||
if (status === "MISPUNCH") summary.mispunches++;
|
|
||||||
if (punctuality === "LATE") summary.late++;
|
|
||||||
} else {
|
|
||||||
summary.absent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee_id: emp.employee_id,
|
|
||||||
employee_code: emp.employee_code,
|
|
||||||
full_name: `${emp.first_name} ${emp.last_name}`,
|
|
||||||
attendance_id,
|
|
||||||
status,
|
|
||||||
check_in,
|
|
||||||
check_out,
|
|
||||||
worked_hours,
|
|
||||||
punctuality
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
date: targetDate,
|
|
||||||
summary,
|
|
||||||
roster
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to generate daily admin report:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminRangeReport = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const fromDate = params.get("from_date");
|
|
||||||
const toDate = params.get("to_date");
|
|
||||||
const branchId = params.get("branch_id"); // Optional filter
|
|
||||||
const departmentId = params.get("department_id"); // Optional filter
|
|
||||||
|
|
||||||
if (!fromDate || !toDate) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing required date boundaries: from_date and to_date (YYYY-MM-DD)." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Dynamically build the EMS query based on branch or department selection
|
|
||||||
let emsQuery = `
|
|
||||||
SELECT e.employee_id, e.employee_code, p.first_name, p.last_name, b.branch_name, d.name as department_name
|
|
||||||
FROM employees e
|
|
||||||
INNER JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
INNER JOIN branches b ON e.branch_id = b.branch_id
|
|
||||||
INNER JOIN contracts c ON e.employee_id = c.employee_id
|
|
||||||
INNER JOIN departments d ON c.department_id = d.department_id
|
|
||||||
WHERE e.is_active = TRUE AND c.status = 'ACTIVE'
|
|
||||||
`;
|
|
||||||
const emsParams: any[] = [];
|
|
||||||
|
|
||||||
if (branchId) {
|
|
||||||
emsQuery += ` AND e.branch_id = ?`;
|
|
||||||
emsParams.push(branchId);
|
|
||||||
}
|
|
||||||
if (departmentId) {
|
|
||||||
emsQuery += ` AND c.department_id = ?`;
|
|
||||||
emsParams.push(departmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [employees]: any = await emsDb.execute(emsQuery, emsParams);
|
|
||||||
|
|
||||||
if (employees.length === 0) {
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, summary: {}, report: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const employeeIds = employees.map((e: any) => e.employee_id);
|
|
||||||
|
|
||||||
// 2. Query AMS for all processed ledger entries matching these exact employees in the date range
|
|
||||||
// Using a parameterized string hack for the IN clause since Deno MySQL handles arrays explicitly
|
|
||||||
const placeholders = employeeIds.map(() => "?").join(",");
|
|
||||||
const [attendanceRows]: any = await amsDb.execute(
|
|
||||||
`SELECT
|
|
||||||
employee_id,
|
|
||||||
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
|
||||||
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
|
|
||||||
ORDER BY work_date ASC`,
|
|
||||||
[fromDate, toDate, ...employeeIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group logs by employee_id for structural assembly
|
|
||||||
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
|
|
||||||
if (!acc[row.employee_id]) acc[row.employee_id] = [];
|
|
||||||
acc[row.employee_id].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Overall global counters for the Admin's upper summary ribbon card metrics
|
|
||||||
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
|
|
||||||
|
|
||||||
const report = employees.map((emp: any) => {
|
|
||||||
const logs = attendanceGrouped[emp.employee_id] || [];
|
|
||||||
|
|
||||||
// Calculate individual metrics summary for this specific employee across the range
|
|
||||||
const individualMetrics = logs.reduce((acc: any, log: any) => {
|
|
||||||
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
|
|
||||||
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
|
|
||||||
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
|
|
||||||
|
|
||||||
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
|
|
||||||
return acc;
|
|
||||||
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
|
|
||||||
|
|
||||||
return {
|
|
||||||
...emp,
|
|
||||||
range_metrics: individualMetrics,
|
|
||||||
attendance_history: logs
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Admin range report compilation failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSingleEmployeeRangeReport = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const employeeId = params.get("employee_id");
|
|
||||||
const fromDate = params.get("from_date");
|
|
||||||
const toDate = params.get("to_date");
|
|
||||||
|
|
||||||
if (!employeeId || !fromDate || !toDate) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing query parameters. Required: employee_id, from_date, to_date." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Grab profile data
|
|
||||||
const [empProfile]: any = await emsDb.execute(
|
|
||||||
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
|
||||||
FROM employees e INNER JOIN partners p ON e.partner_id = p.partner_id WHERE e.employee_id = ?`,
|
|
||||||
[employeeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (empProfile.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Employee profile not found." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grab attendance logs for date-to-date query window
|
|
||||||
const [logs]: any = await amsDb.execute(
|
|
||||||
`SELECT
|
|
||||||
attendance_id,
|
|
||||||
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
|
||||||
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
|
||||||
ORDER BY work_date ASC`,
|
|
||||||
[employeeId, fromDate, toDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
employee: empProfile[0],
|
|
||||||
range: { from: fromDate, to: toDate },
|
|
||||||
total_days: logs.length,
|
|
||||||
history: logs
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Single employee range query failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,326 +0,0 @@
|
|||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
import { AppRole } from "../../shared/auth.ts"; // Importing roles for security checks
|
|
||||||
|
|
||||||
const amsDb = getDbPool("hrms_ams");
|
|
||||||
const emsDb = getDbPool("hrms_ems"); // Used for cross-database joins
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EMPLOYEE: Create a new regularization request
|
|
||||||
*/
|
|
||||||
export const createRegularizationRequest = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing request body." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const {
|
|
||||||
attendance_id,
|
|
||||||
employee_id,
|
|
||||||
regularization_type,
|
|
||||||
target_date,
|
|
||||||
requested_check_in,
|
|
||||||
requested_check_out,
|
|
||||||
reason,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!employee_id || !reason || !regularization_type || !target_date) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
"Missing required fields: employee_id, target_date, regularization_type, and reason.",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch employee's branch_id from EMS so AMS knows which branch this request belongs to
|
|
||||||
const [empRows]: any = await emsDb.execute(
|
|
||||||
`SELECT branch_id FROM employees WHERE employee_id = ?`,
|
|
||||||
[employee_id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (empRows.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Employee not found in EMS.",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branch_id = empRows[0].branch_id;
|
|
||||||
|
|
||||||
// Insert into AMS with the cached branch_id
|
|
||||||
await amsDb.execute(
|
|
||||||
`INSERT INTO attendance_regularizations
|
|
||||||
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
|
|
||||||
[
|
|
||||||
attendance_id || null,
|
|
||||||
employee_id,
|
|
||||||
branch_id,
|
|
||||||
regularization_type,
|
|
||||||
target_date,
|
|
||||||
requested_check_in || null,
|
|
||||||
requested_check_out || null,
|
|
||||||
reason,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
"Regularization request submitted successfully and is pending approval.",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to submit regularization:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ADMIN: Fetch pending regularizations based on their assigned branches
|
|
||||||
*/
|
|
||||||
export const getPendingRegularizations = async (ctx: Context) => {
|
|
||||||
const user = ctx.state.user;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let query = `
|
|
||||||
SELECT
|
|
||||||
r.regularization_id,
|
|
||||||
r.employee_id,
|
|
||||||
r.target_date,
|
|
||||||
r.regularization_type,
|
|
||||||
r.requested_check_in,
|
|
||||||
r.requested_check_out,
|
|
||||||
r.reason,
|
|
||||||
e.employee_code,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name,
|
|
||||||
b.branch_name
|
|
||||||
FROM attendance_regularizations r
|
|
||||||
INNER JOIN hrms_ems.employees e ON r.employee_id = e.employee_id
|
|
||||||
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
|
||||||
INNER JOIN hrms_ems.branches b ON r.branch_id = b.branch_id
|
|
||||||
WHERE r.status = 'PENDING'
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params: any[] = [];
|
|
||||||
|
|
||||||
// If the user is an ADMIN (not SUPER_ADMIN), restrict to their managed branches
|
|
||||||
if (user.role !== AppRole.SUPER_ADMIN) {
|
|
||||||
const branches = user.managed_branches;
|
|
||||||
if (!branches || branches.length === 0) {
|
|
||||||
// If an admin has no branches assigned, return empty list
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, data: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const placeholders = branches.map(() => "?").join(",");
|
|
||||||
query += ` AND r.branch_id IN (${placeholders})`;
|
|
||||||
params.push(...branches);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ` ORDER BY r.target_date DESC`;
|
|
||||||
|
|
||||||
const [rows]: any = await amsDb.execute(query, params);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: rows.length,
|
|
||||||
data: rows,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch pending regularizations:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ADMIN: Review (Approve/Reject) a regularization request
|
|
||||||
*/
|
|
||||||
export const reviewRegularizationRequest = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing request body." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const user = ctx.state.user; // The admin reviewing it
|
|
||||||
const { regularization_id, action } = body; // action can be 'APPROVED' or 'REJECTED'
|
|
||||||
|
|
||||||
if (!regularization_id || !["APPROVED", "REJECTED"].includes(action)) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Invalid payload. Required: regularization_id, action.",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amsConnection = await amsDb.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await amsConnection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Fetch the regularization request details
|
|
||||||
const [requestRows]: any = await amsConnection.execute(
|
|
||||||
`SELECT regularization_id, attendance_id, branch_id, requested_check_in, requested_check_out, status
|
|
||||||
FROM attendance_regularizations WHERE regularization_id = ?`,
|
|
||||||
[regularization_id],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requestRows.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Regularization request not found.",
|
|
||||||
};
|
|
||||||
await amsConnection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = requestRows[0];
|
|
||||||
|
|
||||||
// SECURITY CHECK: Ensure the admin actually manages this branch
|
|
||||||
if (
|
|
||||||
user.role !== AppRole.SUPER_ADMIN &&
|
|
||||||
!user.managed_branches.includes(request.branch_id)
|
|
||||||
) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Access Denied: You do not manage this branch.",
|
|
||||||
};
|
|
||||||
await amsConnection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.status !== "PENDING") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "This request has already been processed.",
|
|
||||||
};
|
|
||||||
await amsConnection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Update the regularization request record status and reviewer ID
|
|
||||||
await amsConnection.execute(
|
|
||||||
`UPDATE attendance_regularizations
|
|
||||||
SET status = ?, reviewed_by_id = ?
|
|
||||||
WHERE regularization_id = ?`,
|
|
||||||
[action, user.employee_id, regularization_id],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. If APPROVED, dynamically recalculate and overwrite the target day's processed ledger row
|
|
||||||
if (action === "APPROVED") {
|
|
||||||
let worked_hours = 0.0;
|
|
||||||
let final_status = "FULL_DAY";
|
|
||||||
let check_in_status = "ON_TIME";
|
|
||||||
|
|
||||||
// Fetch shift details (Fallback pattern)
|
|
||||||
const [empRows]: any = await amsConnection.execute(
|
|
||||||
`SELECT company_id, branch_id FROM hrms_ems.employees WHERE employee_id = ?`,
|
|
||||||
[requestRows[0].employee_id],
|
|
||||||
);
|
|
||||||
const emp = empRows[0];
|
|
||||||
|
|
||||||
const [shiftRows]: any = await amsConnection.execute(
|
|
||||||
`SELECT start_time, grace_period_minutes FROM shifts
|
|
||||||
WHERE (branch_id = ? OR branch_id IS NULL) AND company_id = ?
|
|
||||||
ORDER BY branch_id IS NULL ASC LIMIT 1`,
|
|
||||||
[emp.branch_id, emp.company_id],
|
|
||||||
);
|
|
||||||
const shift = shiftRows[0] ||
|
|
||||||
{ start_time: "10:00:00", grace_period_minutes: 10 };
|
|
||||||
|
|
||||||
if (request.requested_check_in && request.requested_check_out) {
|
|
||||||
const checkInMs = new Date(request.requested_check_in.replace(" ", "T"))
|
|
||||||
.getTime();
|
|
||||||
const checkOutMs = new Date(
|
|
||||||
request.requested_check_out.replace(" ", "T"),
|
|
||||||
).getTime();
|
|
||||||
|
|
||||||
// Handle potential night shift edge case
|
|
||||||
const effectiveCheckOutMs = checkOutMs < checkInMs
|
|
||||||
? checkOutMs + (24 * 60 * 60 * 1000)
|
|
||||||
: checkOutMs;
|
|
||||||
|
|
||||||
worked_hours = Math.round(
|
|
||||||
((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100,
|
|
||||||
) / 100;
|
|
||||||
|
|
||||||
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
|
||||||
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
|
||||||
else final_status = "ABSENT";
|
|
||||||
|
|
||||||
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
|
|
||||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(
|
|
||||||
Number,
|
|
||||||
);
|
|
||||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(
|
|
||||||
Number,
|
|
||||||
);
|
|
||||||
|
|
||||||
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
|
||||||
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 +
|
|
||||||
(shift.grace_period_minutes * 60);
|
|
||||||
|
|
||||||
if (punchTotalSeconds > shiftCutoffSeconds) {
|
|
||||||
check_in_status = "LATE";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update the ledger if an actual attendance record exists
|
|
||||||
if (request.attendance_id) {
|
|
||||||
await amsConnection.execute(
|
|
||||||
`UPDATE processed_daily_attendance
|
|
||||||
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
|
|
||||||
WHERE attendance_id = ?`,
|
|
||||||
[
|
|
||||||
request.requested_check_in,
|
|
||||||
request.requested_check_out,
|
|
||||||
worked_hours,
|
|
||||||
final_status,
|
|
||||||
check_in_status,
|
|
||||||
request.attendance_id,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await amsConnection.commit();
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: `Request has been successfully ${action.toLowerCase()}.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await amsConnection.rollback();
|
|
||||||
console.error("Admin review action failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
amsConnection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { Application } from "@oak/oak";
|
|
||||||
import router from "./routes.ts";
|
|
||||||
|
|
||||||
const app = new Application();
|
|
||||||
const PORT = 8002; // Running AMS on 8002 to avoid conflict with EMS on 8001
|
|
||||||
|
|
||||||
app.use(async (ctx, next) => {
|
|
||||||
console.log(`[AMS] ${ctx.request.method} ${ctx.request.url.pathname}`);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(router.routes());
|
|
||||||
app.use(router.allowedMethods());
|
|
||||||
|
|
||||||
console.log(`AMS Service running on http://localhost:${PORT}`);
|
|
||||||
await app.listen({ port: PORT });
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { Router } from "@oak/oak";
|
|
||||||
import {
|
|
||||||
getRawLogs,
|
|
||||||
processDailyAttendance,
|
|
||||||
getEmployeeSummary,
|
|
||||||
getDailyReport,
|
|
||||||
getAdminRangeReport,
|
|
||||||
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
|
||||||
import {createRegularizationRequest,
|
|
||||||
getPendingRegularizations,
|
|
||||||
reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
|
||||||
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
const apiV1 = new Router();
|
|
||||||
|
|
||||||
apiV1.get("/attendance/logs", getRawLogs);
|
|
||||||
apiV1.post("/attendance/process-daily", processDailyAttendance);
|
|
||||||
|
|
||||||
// Regularization Routes
|
|
||||||
apiV1.post("/attendance/regularize", requireAuth, requireRole([AppRole.EMPLOYEE, AppRole.ADMIN]), createRegularizationRequest);
|
|
||||||
|
|
||||||
// Admin fetches requests assigned to their branches
|
|
||||||
apiV1.get("/attendance/regularize/pending", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingRegularizations);
|
|
||||||
|
|
||||||
// Admin reviews requests
|
|
||||||
apiV1.post("/attendance/regularize/review", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), reviewRegularizationRequest);
|
|
||||||
|
|
||||||
apiV1.get("/attendance/my-summary", getEmployeeSummary);
|
|
||||||
|
|
||||||
apiV1.get("/attendance/daily-report", getDailyReport);
|
|
||||||
apiV1.get("/attendance/admin-report", getAdminRangeReport);
|
|
||||||
apiV1.get("/attendance/employee-range-report", getSingleEmployeeRangeReport);
|
|
||||||
|
|
||||||
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
160
database-dump/hrms_ams_dump.sql
Normal file
160
database-dump/hrms_ams_dump.sql
Normal file
File diff suppressed because one or more lines are too long
454
database-dump/hrms_ems_dump.sql
Normal file
454
database-dump/hrms_ems_dump.sql
Normal file
File diff suppressed because one or more lines are too long
253
database-dump/hrms_lms_dump.sql
Normal file
253
database-dump/hrms_lms_dump.sql
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
-- MySQL dump 10.13 Distrib 8.4.10, for Linux (x86_64)
|
||||||
|
--
|
||||||
|
-- Host: localhost Database: hrms_lms
|
||||||
|
-- ------------------------------------------------------
|
||||||
|
-- Server version 8.4.10
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||||
|
/*!50503 SET NAMES utf8mb4 */;
|
||||||
|
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||||
|
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||||
|
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||||
|
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||||
|
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||||
|
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `branch_work_settings`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `branch_work_settings`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `branch_work_settings` (
|
||||||
|
`setting_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`branch_id` int DEFAULT NULL,
|
||||||
|
`weekly_off_days` json NOT NULL,
|
||||||
|
`effective_from` date NOT NULL,
|
||||||
|
PRIMARY KEY (`setting_id`),
|
||||||
|
KEY `idx_work_settings` (`company_id`,`branch_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `branch_work_settings`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `branch_work_settings` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `branch_work_settings` DISABLE KEYS */;
|
||||||
|
INSERT INTO `branch_work_settings` VALUES (1,1,NULL,'[0]','2026-01-01');
|
||||||
|
/*!40000 ALTER TABLE `branch_work_settings` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `company_holidays`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `company_holidays`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `company_holidays` (
|
||||||
|
`holiday_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`branch_id` int DEFAULT NULL,
|
||||||
|
`calendar_year` int NOT NULL,
|
||||||
|
`holiday_date` date NOT NULL,
|
||||||
|
`holiday_type` enum('MANDATORY','OPTIONAL') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANDATORY',
|
||||||
|
`holiday_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
PRIMARY KEY (`holiday_id`),
|
||||||
|
KEY `idx_holiday_resolver` (`company_id`,`branch_id`,`calendar_year`,`holiday_date`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `company_holidays`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `company_holidays` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `company_holidays` DISABLE KEYS */;
|
||||||
|
INSERT INTO `company_holidays` VALUES (1,1,NULL,2026,'2026-08-15','MANDATORY','Independence Day');
|
||||||
|
/*!40000 ALTER TABLE `company_holidays` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `leave_allocations`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `leave_allocations`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `leave_allocations` (
|
||||||
|
`allocation_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`employee_id` int NOT NULL,
|
||||||
|
`leave_type_id` int NOT NULL,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`calendar_year` int NOT NULL,
|
||||||
|
`granted_days` decimal(4,1) NOT NULL,
|
||||||
|
`used_days` decimal(4,1) DEFAULT '0.0',
|
||||||
|
`status` enum('DRAFT','ACTIVE','EXPIRED') COLLATE utf8mb4_unicode_ci DEFAULT 'ACTIVE',
|
||||||
|
PRIMARY KEY (`allocation_id`),
|
||||||
|
KEY `leave_type_id` (`leave_type_id`),
|
||||||
|
KEY `idx_emp_balance` (`employee_id`,`calendar_year`,`leave_type_id`),
|
||||||
|
CONSTRAINT `leave_allocations_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `leave_allocations`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `leave_allocations` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `leave_allocations` DISABLE KEYS */;
|
||||||
|
INSERT INTO `leave_allocations` VALUES (1,1,1,1,2026,10.0,0.0,'ACTIVE');
|
||||||
|
/*!40000 ALTER TABLE `leave_allocations` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `leave_applications`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `leave_applications`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `leave_applications` (
|
||||||
|
`application_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`employee_id` int NOT NULL,
|
||||||
|
`leave_type_id` int NOT NULL,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`date_from` timestamp NOT NULL,
|
||||||
|
`date_to` timestamp NOT NULL,
|
||||||
|
`number_of_days` decimal(4,1) NOT NULL,
|
||||||
|
`reason` text COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
`status` enum('DRAFT','PENDING','PENDING_LOP','APPROVED','APPROVED_LOP','REJECTED','CANCELLED') COLLATE utf8mb4_unicode_ci DEFAULT 'PENDING',
|
||||||
|
`manager_approved_by` int DEFAULT NULL,
|
||||||
|
`hr_approved_by` int DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`application_id`),
|
||||||
|
KEY `leave_type_id` (`leave_type_id`),
|
||||||
|
KEY `idx_lms_status_lookup` (`employee_id`,`status`),
|
||||||
|
CONSTRAINT `leave_applications_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `leave_applications`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `leave_applications` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `leave_applications` DISABLE KEYS */;
|
||||||
|
/*!40000 ALTER TABLE `leave_applications` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `leave_ledger_transactions`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `leave_ledger_transactions`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `leave_ledger_transactions` (
|
||||||
|
`transaction_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`employee_id` int NOT NULL,
|
||||||
|
`leave_type_id` int NOT NULL,
|
||||||
|
`application_id` int DEFAULT NULL,
|
||||||
|
`calendar_year` int NOT NULL,
|
||||||
|
`calendar_month` int NOT NULL,
|
||||||
|
`transaction_type` enum('CREDIT','DEBIT','LAPSE') COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
`days` decimal(4,1) NOT NULL,
|
||||||
|
`opening_balance` decimal(4,1) NOT NULL,
|
||||||
|
`closing_balance` decimal(4,1) NOT NULL,
|
||||||
|
`transaction_date` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`remarks` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`transaction_id`),
|
||||||
|
KEY `leave_type_id` (`leave_type_id`),
|
||||||
|
KEY `application_id` (`application_id`),
|
||||||
|
KEY `idx_ledger_lookup` (`employee_id`,`calendar_year`,`calendar_month`),
|
||||||
|
CONSTRAINT `leave_ledger_transactions_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`),
|
||||||
|
CONSTRAINT `leave_ledger_transactions_ibfk_2` FOREIGN KEY (`application_id`) REFERENCES `leave_applications` (`application_id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `leave_ledger_transactions`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `leave_ledger_transactions` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `leave_ledger_transactions` DISABLE KEYS */;
|
||||||
|
/*!40000 ALTER TABLE `leave_ledger_transactions` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `leave_policy_rules`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `leave_policy_rules`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `leave_policy_rules` (
|
||||||
|
`rule_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`leave_type_id` int NOT NULL,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`branch_id` int DEFAULT NULL,
|
||||||
|
`calendar_year` int NOT NULL,
|
||||||
|
`yearly_allowance` decimal(4,1) NOT NULL,
|
||||||
|
`max_days_per_month` decimal(4,1) DEFAULT NULL,
|
||||||
|
`max_consecutive_days` decimal(4,1) DEFAULT NULL,
|
||||||
|
`apply_sandwich_policy` tinyint(1) DEFAULT '0',
|
||||||
|
PRIMARY KEY (`rule_id`),
|
||||||
|
KEY `leave_type_id` (`leave_type_id`),
|
||||||
|
KEY `idx_policy_lookup` (`company_id`,`branch_id`,`calendar_year`,`leave_type_id`),
|
||||||
|
CONSTRAINT `leave_policy_rules_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `leave_policy_rules`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `leave_policy_rules` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `leave_policy_rules` DISABLE KEYS */;
|
||||||
|
INSERT INTO `leave_policy_rules` VALUES (1,1,1,NULL,2026,10.0,2.0,NULL,1),(2,2,1,NULL,2026,4.0,NULL,NULL,0);
|
||||||
|
/*!40000 ALTER TABLE `leave_policy_rules` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Table structure for table `leave_types`
|
||||||
|
--
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `leave_types`;
|
||||||
|
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||||
|
/*!50503 SET character_set_client = utf8mb4 */;
|
||||||
|
CREATE TABLE `leave_types` (
|
||||||
|
`leave_type_id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`company_id` int NOT NULL,
|
||||||
|
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
`requires_allocation` tinyint(1) DEFAULT '1',
|
||||||
|
`carry_over_allowed` tinyint(1) DEFAULT '0',
|
||||||
|
`max_carry_over_days` decimal(3,1) DEFAULT '0.0',
|
||||||
|
PRIMARY KEY (`leave_type_id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Dumping data for table `leave_types`
|
||||||
|
--
|
||||||
|
|
||||||
|
LOCK TABLES `leave_types` WRITE;
|
||||||
|
/*!40000 ALTER TABLE `leave_types` DISABLE KEYS */;
|
||||||
|
INSERT INTO `leave_types` VALUES (1,1,'Casual Leave',1,0,0.0),(2,1,'Optional Leave',1,0,0.0);
|
||||||
|
/*!40000 ALTER TABLE `leave_types` ENABLE KEYS */;
|
||||||
|
UNLOCK TABLES;
|
||||||
|
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||||
|
|
||||||
|
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||||
|
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||||
|
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||||
|
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||||
|
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||||
|
|
||||||
|
-- Dump completed on 2026-07-16 10:02:14
|
||||||
8
database-dump/scripts/export-dump.sh
Normal file
8
database-dump/scripts/export-dump.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 1. Export EMS Database
|
||||||
|
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ems' > hrms_ems_dump.sql
|
||||||
|
|
||||||
|
# 2. Export AMS Database
|
||||||
|
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ams' > hrms_ams_dump.sql
|
||||||
|
|
||||||
|
# 3. Export LMS Database
|
||||||
|
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_lms' > hrms_lms_dump.sql
|
||||||
8
database-dump/scripts/import-dump.sh
Normal file
8
database-dump/scripts/import-dump.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 1. Import EMS Data
|
||||||
|
docker exec -i hrms_ems_db mysql -u root -proot hrms_ems < hrms_ems_dump.sql
|
||||||
|
|
||||||
|
# 2. Import AMS Data
|
||||||
|
docker exec -i hrms_ams_db mysql -u root -proot hrms_ams < hrms_ams_dump.sql
|
||||||
|
|
||||||
|
# 3. Import LMS Data
|
||||||
|
docker exec -i hrms_lms_db mysql -u root -proot hrms_lms < hrms_lms_dump.sql
|
||||||
16
deno.json
16
deno.json
@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"workspace": [
|
"workspace": [
|
||||||
"./shared",
|
"packages/core",
|
||||||
"./ems-service",
|
"services/ems",
|
||||||
"./ams-service",
|
"services/ams",
|
||||||
"./lms-service"
|
"services/lms",
|
||||||
|
"services/gateway"
|
||||||
],
|
],
|
||||||
"imports": {
|
"imports": {
|
||||||
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||||
"mysql2": "npm:mysql2@^3.0.0"
|
"mysql2": "npm:mysql2@^3.0.0",
|
||||||
|
"core/": "./packages/core/",
|
||||||
|
"@grpc/grpc-js": "npm:@grpc/grpc-js@^1.10.9",
|
||||||
|
"@bufbuild/protobuf": "npm:@bufbuild/protobuf@^2.2.0",
|
||||||
|
"protobufjs": "npm:protobufjs@^7.4.0",
|
||||||
|
"long": "npm:long@^5.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
hrms_db:
|
ems_db:
|
||||||
image: docker.io/library/mysql:8.4
|
image: docker.io/library/mysql:8.4
|
||||||
container_name: hrms_db
|
container_name: hrms_ems_db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: root
|
||||||
MYSQL_DATABASE: hrms_ems
|
MYSQL_DATABASE: hrms_ems
|
||||||
@ -12,9 +12,54 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./init-db:/docker-entrypoint-initdb.d:Z
|
- ./init-db/01-init-ems.sql:/docker-entrypoint-initdb.d/01-init-ems.sql:Z
|
||||||
- hrms_db_data:/var/lib/mysql
|
- ems_db_data:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ams_db:
|
||||||
|
image: docker.io/library/mysql:8.4
|
||||||
|
container_name: hrms_ams_db
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: hrms_ams
|
||||||
|
MYSQL_USER: admin
|
||||||
|
MYSQL_PASSWORD: admin123
|
||||||
|
ports:
|
||||||
|
- "3307:3306" # Maps host 3307 to container 3306
|
||||||
|
volumes:
|
||||||
|
- ./init-db/02-init-ams.sql:/docker-entrypoint-initdb.d/02-init-ams.sql:Z
|
||||||
|
- ams_db_data:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
lms_db:
|
||||||
|
image: docker.io/library/mysql:8.4
|
||||||
|
container_name: hrms_lms_db
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: hrms_lms
|
||||||
|
MYSQL_USER: admin
|
||||||
|
MYSQL_PASSWORD: admin123
|
||||||
|
ports:
|
||||||
|
- "3308:3306" # Maps host 3308 to container 3306
|
||||||
|
volumes:
|
||||||
|
- ./init-db/03-init-lms.sql:/docker-entrypoint-initdb.d/03-init-lms.sql:Z
|
||||||
|
- lms_db_data:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
proto-compiler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.protos
|
||||||
|
args:
|
||||||
|
HTTP_PROXY: "http://host.containers.internal:8118"
|
||||||
|
HTTPS_PROXY: "http://host.containers.internal:8118"
|
||||||
|
container_name: hrms_proto_compiler
|
||||||
|
volumes:
|
||||||
|
- .:/app:Z
|
||||||
|
working_dir: /app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
hrms_db_data:
|
ems_db_data:
|
||||||
|
ams_db_data:
|
||||||
|
lms_db_data:
|
||||||
@ -1,98 +0,0 @@
|
|||||||
import { getDbPool } from "../../shared/db.ts"
|
|
||||||
|
|
||||||
const pool = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
export const getEmployeeContracts = async (ctx: any) => {
|
|
||||||
const employeeId = ctx.params.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
c.contract_id,
|
|
||||||
c.work_email,
|
|
||||||
c.date_joining,
|
|
||||||
c.probation_days,
|
|
||||||
c.status,
|
|
||||||
c.salary_structure_id,
|
|
||||||
d.name AS department,
|
|
||||||
j.title AS designation
|
|
||||||
FROM contracts c
|
|
||||||
JOIN employees e ON c.employee_id = e.employee_id
|
|
||||||
JOIN departments d ON c.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON c.job_id = j.job_id
|
|
||||||
WHERE c.employee_id = ?
|
|
||||||
ORDER BY c.date_joining DESC`,
|
|
||||||
[employeeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: (rows as any[]).length,
|
|
||||||
data: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createContract = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Expire any currently active contracts for this specific employee
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE contracts SET status = 'EXPIRED' WHERE employee_id = ? AND status = 'ACTIVE'`,
|
|
||||||
[data.employeeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Insert the brand new active contract
|
|
||||||
const [result] = await connection.execute(
|
|
||||||
`INSERT INTO contracts (employee_id, department_id, job_id, date_joining, probation_days, status, salary_structure_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
data.employeeId,
|
|
||||||
data.departmentId,
|
|
||||||
data.jobId,
|
|
||||||
data.dateJoining,
|
|
||||||
data.probationDays,
|
|
||||||
'ACTIVE',
|
|
||||||
data.salaryStructureId
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "New contract executed successfully. Previous contracts expired.",
|
|
||||||
contract_id: (result as any).insertId
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: "Transaction failed: " + (error as Error).message
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,346 +0,0 @@
|
|||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
import { generateNextCode } from "../../shared/sequence.ts";
|
|
||||||
|
|
||||||
const pool = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
export const getEmployees = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
// FIX: Filter by ACTIVE contract to prevent duplicate rows for employees with past contracts
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
e.employee_id,
|
|
||||||
e.employee_code,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name,
|
|
||||||
d.name AS department,
|
|
||||||
j.title AS designation,
|
|
||||||
c.work_email
|
|
||||||
FROM employees e
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
|
|
||||||
JOIN departments d ON c.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON c.job_id = j.job_id
|
|
||||||
WHERE e.is_active = true`,
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: (rows as any[]).length,
|
|
||||||
data: rows,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createEmployee = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
let empCode = data.employeeCode;
|
|
||||||
|
|
||||||
if (!empCode || empCode.trim() === "") {
|
|
||||||
empCode = await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const [partnerResult] = await connection.execute(
|
|
||||||
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
data.firstName,
|
|
||||||
data.lastName,
|
|
||||||
data.dob,
|
|
||||||
data.gender,
|
|
||||||
data.personalEmail,
|
|
||||||
data.personalPhone,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const partnerId = (partnerResult as any).insertId;
|
|
||||||
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
partnerId,
|
|
||||||
data.address.type,
|
|
||||||
data.address.doorNumber,
|
|
||||||
data.address.landmark,
|
|
||||||
data.address.line,
|
|
||||||
data.address.pincode,
|
|
||||||
data.address.district,
|
|
||||||
data.address.state,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [employeeResult] = await connection.execute(
|
|
||||||
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[empCode, partnerId, data.companyId, data.branchId, true],
|
|
||||||
);
|
|
||||||
const employeeId = (employeeResult as any).insertId;
|
|
||||||
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, probation_days, status, salary_structure_id, reporting_to_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
employeeId,
|
|
||||||
data.departmentId,
|
|
||||||
data.jobId,
|
|
||||||
data.work_email,
|
|
||||||
data.dateJoining,
|
|
||||||
data.probationDays,
|
|
||||||
"ACTIVE",
|
|
||||||
data.salaryStructureId,
|
|
||||||
data.reportingToId || null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Employee created successfully",
|
|
||||||
employee_id: employeeId,
|
|
||||||
employee_code: empCode,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
if (errorMessage.includes("Duplicate entry")) {
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"A unique constraint failed. The Employee Code, Personal Email, or Phone number already exists.",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: "Transaction failed: " + errorMessage,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeById = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Fetch core employee, partner, contract, and manager details
|
|
||||||
const [empRows] = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
e.employee_id,
|
|
||||||
e.employee_code,
|
|
||||||
e.is_active,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name,
|
|
||||||
p.dob,
|
|
||||||
p.gender,
|
|
||||||
p.personal_email,
|
|
||||||
p.personal_phone,
|
|
||||||
c.work_email,
|
|
||||||
c.date_joining,
|
|
||||||
c.probation_days,
|
|
||||||
c.status AS contract_status,
|
|
||||||
d.name AS department,
|
|
||||||
d.department_id,
|
|
||||||
j.title AS designation,
|
|
||||||
j.job_id,
|
|
||||||
mgr_p.first_name AS manager_first_name,
|
|
||||||
mgr_p.last_name AS manager_last_name,
|
|
||||||
mgr_e.employee_code AS manager_employee_code
|
|
||||||
FROM employees e
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
|
|
||||||
JOIN departments d ON c.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON c.job_id = j.job_id
|
|
||||||
LEFT JOIN employees mgr_e ON c.reporting_to_id = mgr_e.employee_id
|
|
||||||
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
|
||||||
WHERE e.employee_id = ?`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const empData = empRows as any[];
|
|
||||||
|
|
||||||
if (empData.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Employee not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const employee = empData[0];
|
|
||||||
|
|
||||||
// 2. Fetch all addresses for this partner separately to return as an array
|
|
||||||
const [addrRows] = await pool.query(
|
|
||||||
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
|
|
||||||
FROM addresses
|
|
||||||
WHERE partner_id = (SELECT partner_id FROM employees WHERE employee_id = ?)`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Combine into a single structured JSON response
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
employee_id: employee.employee_id,
|
|
||||||
employee_code: employee.employee_code,
|
|
||||||
is_active: employee.is_active,
|
|
||||||
first_name: employee.first_name,
|
|
||||||
last_name: employee.last_name,
|
|
||||||
dob: employee.dob,
|
|
||||||
gender: employee.gender,
|
|
||||||
personal_email: employee.personal_email,
|
|
||||||
personal_phone: employee.personal_phone,
|
|
||||||
contract: {
|
|
||||||
work_email: employee.work_email,
|
|
||||||
date_joining: employee.date_joining,
|
|
||||||
probation_days: employee.probation_days,
|
|
||||||
status: employee.contract_status,
|
|
||||||
department: employee.department,
|
|
||||||
department_id: employee.department_id,
|
|
||||||
designation: employee.designation,
|
|
||||||
job_id: employee.job_id,
|
|
||||||
manager: employee.manager_employee_code
|
|
||||||
? {
|
|
||||||
employee_code: employee.manager_employee_code,
|
|
||||||
first_name: employee.manager_first_name,
|
|
||||||
last_name: employee.manager_last_name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
addresses: addrRows, // Array of all addresses (PERMANENT, CURRENT, EMERGENCY)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEmployee = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
const body = ctx.request.body;
|
|
||||||
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
const [employeeRows] = await connection.execute(
|
|
||||||
`SELECT partner_id FROM employees WHERE employee_id = ?`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const employees = employeeRows as any[];
|
|
||||||
if (employees.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Employee not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const partnerId = employees[0].partner_id;
|
|
||||||
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE partners
|
|
||||||
SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ?
|
|
||||||
WHERE partner_id = ?`,
|
|
||||||
[
|
|
||||||
data.firstName,
|
|
||||||
data.lastName,
|
|
||||||
data.personalEmail,
|
|
||||||
data.personalPhone,
|
|
||||||
partnerId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE addresses
|
|
||||||
SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
|
|
||||||
WHERE partner_id = ? AND address_type = ?`,
|
|
||||||
[
|
|
||||||
data.address.doorNumber,
|
|
||||||
data.address.landmark,
|
|
||||||
data.address.line,
|
|
||||||
data.address.pincode,
|
|
||||||
data.address.district,
|
|
||||||
data.address.state,
|
|
||||||
partnerId,
|
|
||||||
data.address.type,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Employee profile updated successfully",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: "Update failed: " + (error as Error).message,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEmployee = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE employees SET is_active = false WHERE employee_id = ?`,
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateResult = result as any;
|
|
||||||
|
|
||||||
if (updateResult.affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Employee not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Employee account deactivated successfully",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
error: "Deactivation failed: " + (error as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,443 +0,0 @@
|
|||||||
import { getDbPool } from "../../shared/db.ts"
|
|
||||||
import {generateNextCode} from "../../shared/sequence.ts"
|
|
||||||
|
|
||||||
const pool = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
export const getCompanies = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(`SELECT company_id, name FROM companies`);
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, data: rows };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBranches = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(`SELECT branch_id, branch_name,code, company_id FROM branches`);
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, data: rows };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDepartments = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(`SELECT department_id, name, parent_id FROM departments`);
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, data: rows };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getJobs = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(`SELECT job_id, title, department_id FROM job_positions`);
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, data: rows };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// COMPANIES MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export const createCompany = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO companies (name, parent_id) VALUES (?, ?)`,
|
|
||||||
[data.name, data.parentId || null]
|
|
||||||
);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCompany = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE companies SET name = ?, parent_id = ? WHERE company_id = ?`,
|
|
||||||
[data.name, data.parentId || null, id]
|
|
||||||
);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Company not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Company updated successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCompany = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Company not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Company deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
// Catch ON DELETE RESTRICT from employees table
|
|
||||||
if (errorMessage.includes("foreign key constraint fails")) {
|
|
||||||
errorMessage = "Cannot delete company. It is currently assigned to one or more employees.";
|
|
||||||
}
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// BRANCHES MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export const createBranch = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
let branchCode = data.code;
|
|
||||||
|
|
||||||
// If no code is provided, automatically generate a sequential one
|
|
||||||
if (!branchCode || branchCode.trim() === "") {
|
|
||||||
// Get first 3 letters (e.g., "Bengaluru" -> "BEN")
|
|
||||||
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
|
|
||||||
|
|
||||||
// Generate: Sequence ID 'BRANCH_BEN', Prefix 'BEN-', Padding 3 -> Output: 'BEN-001'
|
|
||||||
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO branches (company_id, branch_name, code) VALUES (?, ?, ?)`,
|
|
||||||
[data.companyId, data.branchName, branchCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Branch created successfully",
|
|
||||||
branch_id: (result as any).insertId,
|
|
||||||
code: branchCode
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
if (errorMessage.includes("Duplicate entry")) {
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = { success: false, error: `The branch code '${branchCode}' already exists.` };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateBranch = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE branches SET company_id = ?, branch_name = ?, code = ? WHERE branch_id = ?`,
|
|
||||||
[data.companyId, data.branchName, data.code, id]
|
|
||||||
);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Branch not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Branch updated successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBranch = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Branch not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Branch deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
// Catch ON DELETE RESTRICT from employees table
|
|
||||||
if (errorMessage.includes("foreign key constraint fails")) {
|
|
||||||
errorMessage = "Cannot delete branch. It is currently assigned to one or more employees.";
|
|
||||||
}
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// DEPARTMENTS MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const createDepartment = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
|
|
||||||
if (!data.companyId || !data.branchId || !data.name) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "companyId, branchId, and name are required." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Insert the department
|
|
||||||
const [result] = await connection.execute(
|
|
||||||
`INSERT INTO departments (company_id, branch_id, name, parent_id) VALUES (?, ?, ?, ?)`,
|
|
||||||
[data.companyId, data.branchId, data.name, data.parentId ?? null]
|
|
||||||
);
|
|
||||||
const departmentId = (result as any).insertId;
|
|
||||||
|
|
||||||
// 2. If a manager is provided, add them to the junction table
|
|
||||||
if (data.managerId) {
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
|
|
||||||
[departmentId, data.managerId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Department created",
|
|
||||||
department_id: departmentId
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDepartment = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Update core department details
|
|
||||||
const [result] = await connection.execute(
|
|
||||||
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ? WHERE department_id = ?`,
|
|
||||||
[data.companyId ?? null, data.branchId ?? null, data.name ?? null, data.parentId ?? null, id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Department not found" };
|
|
||||||
await connection.rollback();
|
|
||||||
connection.release();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Manage reassignment if a new manager is passed in the update
|
|
||||||
if (data.managerId) {
|
|
||||||
// For simplicity in this update endpoint, we overwrite the existing managers.
|
|
||||||
// You can create a dedicated POST /departments/:id/managers endpoint later for multi-manager logic.
|
|
||||||
await connection.execute(`DELETE FROM department_managers WHERE department_id = ?`, [id]);
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
|
|
||||||
[id, data.managerId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Department updated successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const deleteDepartment = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Department not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Department deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
// Catch ON DELETE RESTRICT from contracts table
|
|
||||||
if (errorMessage.includes("foreign key constraint fails")) {
|
|
||||||
errorMessage = "Cannot delete department. There are active or historical contracts tied to this department.";
|
|
||||||
}
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// JOB POSITIONS MANAGEMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
export const createJob = async (ctx: any) => {
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
|
|
||||||
if (!data.departmentId || !data.title) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "departmentId and title are required fields." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO job_positions (department_id, title, description) VALUES (?, ?, ?)`,
|
|
||||||
[data.departmentId, data.title, data.description ?? null]
|
|
||||||
);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateJob = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
const body = ctx.request.body;
|
|
||||||
if (body.type() !== "json") {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await body.json();
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE job_positions SET department_id = ?, title = ?, description = ? WHERE job_id = ?`,
|
|
||||||
[data.departmentId ?? null, data.title ?? null, data.description ?? null, id]
|
|
||||||
);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Job not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Job updated successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: deleteJob function remains the same as before.
|
|
||||||
|
|
||||||
export const deleteJob = async (ctx: any) => {
|
|
||||||
const id = ctx.params.id;
|
|
||||||
try {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
|
|
||||||
if ((result as any).affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Job not found" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Job deleted successfully" };
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage = (error as Error).message;
|
|
||||||
// Catch ON DELETE RESTRICT from contracts table
|
|
||||||
if (errorMessage.includes("foreign key constraint fails")) {
|
|
||||||
errorMessage = "Cannot delete job position. It is currently linked to one or more employee contracts.";
|
|
||||||
}
|
|
||||||
ctx.response.status = 409;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
|
|
||||||
const emsPool = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
export const bulkSeedEmployees = async (ctx: Context) => {
|
|
||||||
const body = ctx.request.hasBody ? await ctx.request.body.json() : [];
|
|
||||||
|
|
||||||
if (!Array.isArray(body) || body.length === 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Provide an array of employee logs." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await emsPool.getConnection();
|
|
||||||
let insertedCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
for (const item of body) {
|
|
||||||
const { emp_code, name, designation, department, email } = item;
|
|
||||||
|
|
||||||
const nameParts = name.trim().split(" ");
|
|
||||||
const firstName = nameParts[0] || "Employee";
|
|
||||||
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
|
||||||
|
|
||||||
// 1. Insert into partners
|
|
||||||
const [partnerResult]: any = await connection.execute(
|
|
||||||
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
|
||||||
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
|
|
||||||
[firstName, lastName, `personal.${email}`, `MOCK_${emp_code}`]
|
|
||||||
);
|
|
||||||
const partnerId = partnerResult.insertId;
|
|
||||||
|
|
||||||
// 2. Ensure department exists (FIXED: Added branch_id to satisfy NOT NULL constraint)
|
|
||||||
const [deptResult]: any = await connection.execute(
|
|
||||||
`INSERT INTO departments (company_id, branch_id, name)
|
|
||||||
VALUES (1, 1, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
|
||||||
[department || "General"]
|
|
||||||
);
|
|
||||||
const departmentId = deptResult.insertId;
|
|
||||||
|
|
||||||
// 3. Ensure job position exists (FIXED: Linked to department_id instead of company_id)
|
|
||||||
const [jobResult]: any = await connection.execute(
|
|
||||||
`INSERT INTO job_positions (department_id, title)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
|
||||||
[departmentId, designation || "Trainee"]
|
|
||||||
);
|
|
||||||
const jobId = jobResult.insertId;
|
|
||||||
|
|
||||||
// 4. Create core Employee record
|
|
||||||
const [empResult]: any = await connection.execute(
|
|
||||||
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
|
||||||
VALUES (?, ?, 1, 1, TRUE)
|
|
||||||
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
|
|
||||||
[emp_code, partnerId]
|
|
||||||
);
|
|
||||||
const employeeId = empResult.insertId;
|
|
||||||
|
|
||||||
// 5. Establish operational Contract
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id)
|
|
||||||
VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100)
|
|
||||||
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
|
|
||||||
[employeeId, departmentId, jobId, email]
|
|
||||||
);
|
|
||||||
|
|
||||||
insertedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully seeded/aligned ${insertedCount} normalized employee profiles.`,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error("Seeding failed, changes rolled back:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkMapHierarchy = async (ctx: Context) => {
|
|
||||||
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
|
||||||
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
|
|
||||||
|
|
||||||
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await emsPool.getConnection();
|
|
||||||
let contractsUpdated = 0;
|
|
||||||
let departmentsUpdated = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Map Employees to their Managers (Contracts table)
|
|
||||||
for (const mapping of employeeHierarchy) {
|
|
||||||
const { emp_code, manager_code } = mapping;
|
|
||||||
if (!emp_code || !manager_code) continue;
|
|
||||||
|
|
||||||
const [result]: any = await connection.execute(
|
|
||||||
`UPDATE contracts c
|
|
||||||
JOIN employees e ON c.employee_id = e.employee_id
|
|
||||||
JOIN employees m ON m.employee_code = ?
|
|
||||||
SET c.reporting_to_id = m.employee_id
|
|
||||||
WHERE e.employee_code = ? AND c.status = 'ACTIVE'`,
|
|
||||||
[manager_code, emp_code]
|
|
||||||
);
|
|
||||||
if (result.affectedRows > 0) contractsUpdated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Map Departments to Managers and Parent Departments
|
|
||||||
for (const dept of departmentHierarchy) {
|
|
||||||
const { department_name, manager_code, parent_department_name } = dept;
|
|
||||||
if (!department_name) continue;
|
|
||||||
|
|
||||||
// FIXED: Use department_managers junction table instead of departments.manager_id
|
|
||||||
if (manager_code) {
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT IGNORE INTO department_managers (department_id, employee_id)
|
|
||||||
SELECT d.department_id, m.employee_id
|
|
||||||
FROM departments d
|
|
||||||
JOIN employees m ON m.employee_code = ?
|
|
||||||
WHERE d.name = ?`,
|
|
||||||
[manager_code, department_name]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Parent Department Hierarchy
|
|
||||||
if (parent_department_name) {
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE departments d
|
|
||||||
JOIN departments p ON p.name = ?
|
|
||||||
SET d.parent_id = p.department_id
|
|
||||||
WHERE d.name = ?`,
|
|
||||||
[parent_department_name, department_name]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
departmentsUpdated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Hierarchy mapping completed successfully.",
|
|
||||||
metrics: { contractsUpdated, departmentsProcessed: departmentsUpdated }
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error("Hierarchy mapping failed:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { Application } from "@oak/oak";
|
|
||||||
import router from "./routes.ts";
|
|
||||||
|
|
||||||
const app = new Application();
|
|
||||||
|
|
||||||
// Register the routes and allowed methods from routes.ts
|
|
||||||
app.use(router.routes());
|
|
||||||
app.use(router.allowedMethods());
|
|
||||||
|
|
||||||
console.log("EMS Service running on http://localhost:8001");
|
|
||||||
await app.listen({ port: 8001 });
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
import { Router } from "@oak/oak";
|
|
||||||
import {
|
|
||||||
getEmployees,
|
|
||||||
createEmployee,
|
|
||||||
getEmployeeById,
|
|
||||||
updateEmployee,
|
|
||||||
deleteEmployee
|
|
||||||
} from "./controllers/employee.controller.ts";
|
|
||||||
import {
|
|
||||||
getCompanies, createCompany, updateCompany, deleteCompany,
|
|
||||||
getBranches, createBranch, updateBranch, deleteBranch,
|
|
||||||
getDepartments, createDepartment, updateDepartment, deleteDepartment,
|
|
||||||
getJobs, createJob, updateJob, deleteJob
|
|
||||||
} from "./controllers/lookup.controller.ts";
|
|
||||||
import {
|
|
||||||
getEmployeeContracts,
|
|
||||||
createContract
|
|
||||||
} from "./controllers/contract.controller.ts";
|
|
||||||
import {
|
|
||||||
bulkSeedEmployees,
|
|
||||||
bulkMapHierarchy
|
|
||||||
} from "./controllers/system.controller.ts";
|
|
||||||
|
|
||||||
// Shared authentication and authorization middleware
|
|
||||||
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EMPLOYEE CORE DOMAIN
|
|
||||||
// Manages the corporate identity (employees) and personal data (partners).
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Retrieve a list of all active employees. Visible to management and administrators.
|
|
||||||
router.get("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
|
|
||||||
|
|
||||||
// Retrieve a single employee's comprehensive profile (identity, address, contract). Accessible to any authenticated user.
|
|
||||||
router.get("/api/v1/employees/:id", requireAuth, getEmployeeById);
|
|
||||||
|
|
||||||
// Create a new employee profile and initial contract. Restricted to administrators.
|
|
||||||
router.post("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
|
|
||||||
|
|
||||||
// Update an existing employee's personal or address data. Restricted to administrators.
|
|
||||||
router.put("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
|
|
||||||
|
|
||||||
// Soft-delete (deactivate) an employee account. Restricted to administrators.
|
|
||||||
router.delete("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONTRACT & ASSIGNMENT DOMAIN
|
|
||||||
// Manages operational assignments, reporting hierarchies, and job roles.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Retrieve the contract history for an individual employee.
|
|
||||||
router.get("/api/v1/employees/:id/contracts", requireAuth, getEmployeeContracts);
|
|
||||||
|
|
||||||
// Expire current active contracts and execute a new employment agreement.
|
|
||||||
router.post("/api/v1/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ORGANIZATIONAL LOOKUP DOMAIN
|
|
||||||
// Read-only reference endpoints for the frontend UI to populate select options.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Fetch legal entities and parent group structures.
|
|
||||||
router.get("/api/v1/companies", requireAuth, getCompanies);
|
|
||||||
router.post("/api/v1/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
|
|
||||||
router.put("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
|
|
||||||
router.delete("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
|
|
||||||
|
|
||||||
// Fetch structural branch offices and physical locations.
|
|
||||||
router.get("/api/v1/branches", requireAuth, getBranches);
|
|
||||||
router.post("/api/v1/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
|
|
||||||
router.put("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
|
|
||||||
router.delete("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
|
|
||||||
|
|
||||||
// List corporate departments and organizational chart reporting lines.
|
|
||||||
router.get("/api/v1/departments", requireAuth, getDepartments);
|
|
||||||
router.post("/api/v1/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
|
|
||||||
router.put("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
|
|
||||||
router.delete("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
|
|
||||||
|
|
||||||
// List company designations and employment titles.
|
|
||||||
router.get("/api/v1/jobs", requireAuth, getJobs);
|
|
||||||
router.post("/api/v1/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
|
|
||||||
router.put("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
|
|
||||||
router.delete("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SYSTEM & MIGRATION DOMAIN
|
|
||||||
// High-risk administrative endpoints for bulk data execution.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Process raw bulk data to seed initial employee/partner structures into the DB.
|
|
||||||
router.post("/api/v1/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
|
|
||||||
|
|
||||||
// Execute mapping script to establish reporting_to_id and manager_id links.
|
|
||||||
router.post("/api/v1/system/map-hierarchy", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkMapHierarchy);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { Router } from "@oak/oak";
|
|
||||||
import {
|
|
||||||
getEmployees,
|
|
||||||
createEmployee,
|
|
||||||
getEmployeeById,
|
|
||||||
updateEmployee,
|
|
||||||
deleteEmployee
|
|
||||||
} from "./controllers/employee.controller.ts";
|
|
||||||
import {
|
|
||||||
getCompanies,
|
|
||||||
getBranches,
|
|
||||||
getDepartments,
|
|
||||||
getJobs
|
|
||||||
} from "./controllers/lookup.controller.ts";
|
|
||||||
import {
|
|
||||||
getEmployeeContracts,
|
|
||||||
createContract
|
|
||||||
} from "./controllers/contract.controller.ts";
|
|
||||||
|
|
||||||
import {
|
|
||||||
bulkSeedEmployees,
|
|
||||||
bulkMapHierarchy
|
|
||||||
} from "./controllers/system.controller.ts"
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
// --- Employee Core Endpoints ---
|
|
||||||
|
|
||||||
// Retrieve all employees
|
|
||||||
router.get("/api/v1/employees", getEmployees);
|
|
||||||
|
|
||||||
// Retrieve a single employee's comprehensive profile
|
|
||||||
router.get("/api/v1/employees/:id", getEmployeeById);
|
|
||||||
|
|
||||||
// Create a new employee
|
|
||||||
router.post("/api/v1/employees", createEmployee);
|
|
||||||
|
|
||||||
// Update an existing employee
|
|
||||||
router.put("/api/v1/employees/:id", updateEmployee);
|
|
||||||
|
|
||||||
// Deactivate an employee
|
|
||||||
router.delete("/api/v1/employees/:id", deleteEmployee);
|
|
||||||
|
|
||||||
// --- Contract Endpoints ---
|
|
||||||
|
|
||||||
//Retrieve contract history for an individual employee
|
|
||||||
router.get("/api/v1/employees/:id/contracts", getEmployeeContracts);
|
|
||||||
|
|
||||||
//Terminate, transition, or execute a new employment agreement.
|
|
||||||
router.post("/api/v1/contracts", createContract);
|
|
||||||
|
|
||||||
// --- Organizational Lookup Endpoints ---
|
|
||||||
|
|
||||||
// Fetch legal entities and parent group structures.
|
|
||||||
router.get("/api/v1/companies", getCompanies);
|
|
||||||
|
|
||||||
//Fetch structural branch offices
|
|
||||||
router.get("/api/v1/branches", getBranches);
|
|
||||||
|
|
||||||
//List corporate departments and organizational chart reporting lines
|
|
||||||
router.get("/api/v1/departments", getDepartments);
|
|
||||||
|
|
||||||
//List company designations and employment titles.
|
|
||||||
router.get("/api/v1/jobs", getJobs);
|
|
||||||
|
|
||||||
router.post("/api/v1/system/seed-employees", bulkSeedEmployees);
|
|
||||||
router.post("/api/v1/system/map-hierarchy", bulkMapHierarchy);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
5006
generated/ams.ts
Normal file
5006
generated/ams.ts
Normal file
File diff suppressed because it is too large
Load Diff
7257
generated/ems.ts
Normal file
7257
generated/ems.ts
Normal file
File diff suppressed because it is too large
Load Diff
4558
generated/lms.ts
Normal file
4558
generated/lms.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,10 @@ USE hrms_ems;
|
|||||||
-- 1. Legal Entities
|
-- 1. Legal Entities
|
||||||
CREATE TABLE companies (
|
CREATE TABLE companies (
|
||||||
company_id INT AUTO_INCREMENT PRIMARY KEY,
|
company_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
parent_id INT NULL,
|
parent_id INT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (parent_id) REFERENCES companies(company_id) ON DELETE SET NULL
|
FOREIGN KEY (parent_id) REFERENCES companies(company_id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -16,6 +18,7 @@ CREATE TABLE branches (
|
|||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
branch_name VARCHAR(100) NOT NULL,
|
branch_name VARCHAR(100) NOT NULL,
|
||||||
code VARCHAR(20) NOT NULL UNIQUE,
|
code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -44,6 +47,7 @@ CREATE TABLE addresses (
|
|||||||
pincode VARCHAR(10) NOT NULL,
|
pincode VARCHAR(10) NOT NULL,
|
||||||
district VARCHAR(50) NOT NULL,
|
district VARCHAR(50) NOT NULL,
|
||||||
state VARCHAR(50) NOT NULL,
|
state VARCHAR(50) NOT NULL,
|
||||||
|
UNIQUE KEY uq_partner_address_type (partner_id, address_type),
|
||||||
FOREIGN KEY (partner_id) REFERENCES partners(partner_id) ON DELETE CASCADE
|
FOREIGN KEY (partner_id) REFERENCES partners(partner_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@ -52,8 +56,10 @@ CREATE TABLE departments (
|
|||||||
department_id INT AUTO_INCREMENT PRIMARY KEY,
|
department_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id INT NOT NULL,
|
company_id INT NOT NULL,
|
||||||
branch_id INT NOT NULL,
|
branch_id INT NOT NULL,
|
||||||
|
department_code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
name VARCHAR(50) NOT NULL,
|
name VARCHAR(50) NOT NULL,
|
||||||
parent_id INT NULL,
|
parent_id INT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
|
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
|
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
|
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
|
||||||
@ -63,8 +69,10 @@ CREATE TABLE departments (
|
|||||||
CREATE TABLE job_positions (
|
CREATE TABLE job_positions (
|
||||||
job_id INT AUTO_INCREMENT PRIMARY KEY,
|
job_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
department_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
|
job_code VARCHAR(20) NOT NULL UNIQUE,
|
||||||
title VARCHAR(50) NOT NULL,
|
title VARCHAR(50) NOT NULL,
|
||||||
description TEXT NULL,
|
description TEXT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@ -82,34 +90,61 @@ CREATE TABLE employees (
|
|||||||
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT
|
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 8. Legal & Operational Contracts
|
-- 8. Legal & Compensation Terms (Changes rarely)
|
||||||
CREATE TABLE contracts (
|
CREATE TABLE employment_terms (
|
||||||
contract_id INT AUTO_INCREMENT PRIMARY KEY,
|
term_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
work_email VARCHAR(100) NOT NULL,
|
||||||
|
date_joining DATE NOT NULL,
|
||||||
|
date_ended DATE NULL,
|
||||||
|
probation_days INT DEFAULT 90,
|
||||||
|
salary_structure_id INT NOT NULL,
|
||||||
|
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 9. Organizational Assignments (Changes on promotion/transfer)
|
||||||
|
CREATE TABLE employee_assignments (
|
||||||
|
assignment_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
employee_id INT NOT NULL,
|
employee_id INT NOT NULL,
|
||||||
department_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
job_id INT NOT NULL,
|
job_id INT NOT NULL,
|
||||||
reporting_to_id INT NULL,
|
reporting_to_id INT NULL,
|
||||||
work_email VARCHAR(100) NOT NULL UNIQUE,
|
change_reason ENUM('HIRE', 'TRANSFER', 'PROMOTION', 'MANAGER_CHANGE', 'REORG') NOT NULL,
|
||||||
date_joining DATE NOT NULL,
|
effective_from DATE NOT NULL,
|
||||||
probation_days INT DEFAULT 90,
|
effective_to DATE NULL,
|
||||||
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
|
is_current BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
salary_structure_id INT NOT NULL,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
|
||||||
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
|
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (reporting_to_id) REFERENCES employees(employee_id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 9. Department Managers (Junction Table)
|
-- 10. Department Managers (Junction Table with History)
|
||||||
CREATE TABLE department_managers (
|
CREATE TABLE department_managers (
|
||||||
department_id INT NOT NULL,
|
department_id INT NOT NULL,
|
||||||
employee_id INT NOT NULL,
|
employee_id INT NOT NULL,
|
||||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_current BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
removed_at TIMESTAMP NULL,
|
||||||
PRIMARY KEY (department_id, employee_id),
|
PRIMARY KEY (department_id, employee_id),
|
||||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
|
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 10. System Sequences
|
-- 11. Branch Admins (For AMS/LMS routing)
|
||||||
|
CREATE TABLE branch_admins (
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
branch_id INT NOT NULL,
|
||||||
|
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (employee_id, branch_id),
|
||||||
|
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 12. System Sequences
|
||||||
CREATE TABLE system_sequences (
|
CREATE TABLE system_sequences (
|
||||||
sequence_id VARCHAR(50) PRIMARY KEY,
|
sequence_id VARCHAR(50) PRIMARY KEY,
|
||||||
prefix VARCHAR(10) NOT NULL,
|
prefix VARCHAR(10) NOT NULL,
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
-- Select the right database context
|
|
||||||
USE hrms_ems;
|
|
||||||
|
|
||||||
-- 1. Create the Company
|
|
||||||
INSERT INTO companies (company_id, name, parent_id)
|
|
||||||
VALUES (1, 'CliniLaunch Research', NULL);
|
|
||||||
|
|
||||||
-- 2. Create a Branch
|
|
||||||
INSERT INTO branches (branch_id, company_id, branch_name, code)
|
|
||||||
VALUES (1, 1, 'Bengaluru Main', 'BLR01');
|
|
||||||
|
|
||||||
-- 3. Define Departments
|
|
||||||
INSERT INTO departments (department_id, company_id, name, parent_id, manager_id)
|
|
||||||
VALUES
|
|
||||||
(1, 1, 'Training (PT)', NULL, NULL),
|
|
||||||
(2, 1, 'IT', NULL, NULL);
|
|
||||||
|
|
||||||
-- 4. Authorize Job Positions
|
|
||||||
INSERT INTO job_positions (job_id, company_id, title, description)
|
|
||||||
VALUES
|
|
||||||
(1, 1, 'Executive-II (PT)', 'Part-time executive role for training.'),
|
|
||||||
(2, 1, 'Trainee', 'Entry level IT trainee.');
|
|
||||||
|
|
||||||
-- 5. Create Partners (The personal identity)
|
|
||||||
INSERT INTO partners (partner_id, first_name, last_name, dob, gender, personal_email, personal_phone, profile_picture_url)
|
|
||||||
VALUES
|
|
||||||
(1, 'Blessy', 'Paul', '1995-06-15', 'Female', 'blessy.personal@example.com', '9876543210', NULL),
|
|
||||||
(2, 'Adhvaidh', 'Prasad', '1998-11-22', 'Male', 'adhvaidh.personal@example.com', '8765432109', NULL);
|
|
||||||
|
|
||||||
-- 6. Add Addresses for the Partners
|
|
||||||
INSERT INTO addresses (address_id, partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
|
||||||
VALUES
|
|
||||||
(1, 1, 'CURRENT', '42/A', 'Near Metro', 'MG Road', '560001', 'Bengaluru', 'Karnataka'),
|
|
||||||
(2, 2, 'PERMANENT', '15-B', 'Tech Park', 'Whitefield', '560066', 'Bengaluru', 'Karnataka');
|
|
||||||
|
|
||||||
-- 7. Employ the Partners
|
|
||||||
INSERT INTO employees (employee_id, employee_code, partner_id, company_id, branch_id, is_active)
|
|
||||||
VALUES
|
|
||||||
(1, 'CLRI18', 1, 1, 1, TRUE),
|
|
||||||
(2, 'CLRI298', 2, 1, 1, TRUE);
|
|
||||||
|
|
||||||
-- 8. Assign Contracts
|
|
||||||
INSERT INTO contracts (contract_id, employee_id, department_id, job_id, reporting_to_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
|
||||||
VALUES
|
|
||||||
(1, 1, 1, 1, NULL, 'Blessy.Paul@clinilaunchresearch.in', '2023-01-10', 90, 'ACTIVE', 101),
|
|
||||||
(2, 2, 2, 2, 1, 'adhvaidh.P@clinilaunchresearch.in', '2023-05-20', 180, 'ACTIVE', 102);
|
|
||||||
@ -51,8 +51,9 @@ CREATE TABLE processed_daily_attendance (
|
|||||||
CREATE TABLE attendance_regularizations (
|
CREATE TABLE attendance_regularizations (
|
||||||
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
||||||
|
branch_id INT NOT NULL,
|
||||||
attendance_id INT NULL, -- Internal FK to processed ledger row
|
attendance_id INT NULL, -- Internal FK to processed ledger row
|
||||||
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
|
regularization_type ENUM('MISPUNCH', 'HALF_DAY', 'WFH_REQUEST', 'ON_DUTY') NOT NULL;
|
||||||
target_date DATE NOT NULL,
|
target_date DATE NOT NULL,
|
||||||
requested_check_in TIMESTAMP NULL,
|
requested_check_in TIMESTAMP NULL,
|
||||||
requested_check_out TIMESTAMP NULL,
|
requested_check_out TIMESTAMP NULL,
|
||||||
@ -58,6 +58,8 @@ CREATE TABLE leave_applications (
|
|||||||
status ENUM('DRAFT', 'PENDING', 'PENDING_LOP', 'APPROVED', 'APPROVED_LOP', 'REJECTED', 'CANCELLED') DEFAULT 'PENDING',
|
status ENUM('DRAFT', 'PENDING', 'PENDING_LOP', 'APPROVED', 'APPROVED_LOP', 'REJECTED', 'CANCELLED') DEFAULT 'PENDING',
|
||||||
manager_approved_by INT NULL, -- Logical Reference to EMS employees table (Approver)
|
manager_approved_by INT NULL, -- Logical Reference to EMS employees table (Approver)
|
||||||
hr_approved_by INT NULL,
|
hr_approved_by INT NULL,
|
||||||
|
approver_role VARCHAR(20) NOT NULL DEFAULT 'MANAGER',
|
||||||
|
applicant_role VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE',
|
||||||
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id) ON DELETE RESTRICT,
|
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id) ON DELETE RESTRICT,
|
||||||
INDEX idx_lms_status_lookup (employee_id, status)
|
INDEX idx_lms_status_lookup (employee_id, status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@ -1,153 +0,0 @@
|
|||||||
// lms-service/controllers/admin.controller.ts
|
|
||||||
|
|
||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
|
|
||||||
const db = getDbPool("hrms_lms");
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// LEAVE TYPES
|
|
||||||
// ==========================================
|
|
||||||
export const createLeaveType = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const { company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days } = body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result]: any = await db.execute(
|
|
||||||
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[company_id, name, requires_allocation ?? true, carry_over_allowed ?? false, max_carry_over_days ?? 0.0]
|
|
||||||
);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Leave type created", leave_type_id: result.insertId };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLeaveTypes = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const companyId = params.get("company_id");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT * FROM leave_types WHERE company_id = ?`,
|
|
||||||
[companyId]
|
|
||||||
);
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, count: rows.length, data: rows };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// POLICY RULES
|
|
||||||
// ==========================================
|
|
||||||
export const createPolicyRule = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result]: any = await db.execute(
|
|
||||||
`INSERT INTO leave_policy_rules
|
|
||||||
(leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
body.leave_type_id,
|
|
||||||
body.company_id,
|
|
||||||
body.branch_id || null,
|
|
||||||
body.calendar_year,
|
|
||||||
body.yearly_allowance,
|
|
||||||
body.max_days_per_month || null,
|
|
||||||
body.max_consecutive_days || null,
|
|
||||||
body.apply_sandwich_policy ?? false
|
|
||||||
]
|
|
||||||
);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Policy rule created", rule_id: result.insertId };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// HOLIDAY CALENDAR
|
|
||||||
// ==========================================
|
|
||||||
export const createCompanyHoliday = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await ctx.request.body.json(); // Expects an array of holiday objects for bulk insert
|
|
||||||
|
|
||||||
if (!Array.isArray(body)) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Expected an array of holidays for bulk insert." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Map the array of objects to a flat array for MySQL bulk insert
|
|
||||||
const values = body.map(h => [
|
|
||||||
h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name
|
|
||||||
]);
|
|
||||||
|
|
||||||
await db.query(
|
|
||||||
`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`,
|
|
||||||
[values]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: `${values.length} holidays inserted successfully.` };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORK SETTINGS
|
|
||||||
// ==========================================
|
|
||||||
export const updateWorkSettings = async (ctx: Context) => {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const { company_id, branch_id, weekly_off_days, effective_from } = body;
|
|
||||||
|
|
||||||
// weekly_off_days should be an array like [0] for Sunday. MySQL JSON column accepts stringified arrays.
|
|
||||||
const offDaysJson = JSON.stringify(weekly_off_days);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Upsert logic: If settings exist for this branch/company, update them. Otherwise, insert.
|
|
||||||
await db.execute(
|
|
||||||
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
|
|
||||||
[company_id, branch_id || null, offDaysJson, effective_from]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = { success: true, message: "Work settings updated successfully." };
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
// lms-service/controllers/employee.controller.ts
|
|
||||||
|
|
||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
import { AppRole } from "../../shared/auth.ts";
|
|
||||||
|
|
||||||
const db = getDbPool("hrms_lms");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EMPLOYEE: Fetch leave balances for the dashboard
|
|
||||||
* Query Params: ?year=2026 (optional, defaults to current year)
|
|
||||||
*/
|
|
||||||
export const getLeaveBalances = async (ctx: Context) => {
|
|
||||||
const user = ctx.state.user;
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
|
|
||||||
// Determine target employee ID (Admins can check others, Employees can only check themselves)
|
|
||||||
let targetEmployeeId = user.employee_id;
|
|
||||||
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
|
|
||||||
targetEmployeeId = Number(params.get("employee_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = Number(params.get("year")) || new Date().getFullYear();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT
|
|
||||||
lt.leave_type_id,
|
|
||||||
lt.name AS leave_type_name,
|
|
||||||
la.granted_days,
|
|
||||||
la.used_days,
|
|
||||||
(la.granted_days - la.used_days) AS available_balance
|
|
||||||
FROM leave_allocations la
|
|
||||||
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.employee_id = ? AND la.calendar_year = ? AND la.status = 'ACTIVE'`,
|
|
||||||
[targetEmployeeId, year]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
employee_id: targetEmployeeId,
|
|
||||||
year: year,
|
|
||||||
balances: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch leave balances:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EMPLOYEE: Fetch leave application history
|
|
||||||
* Query Params: ?year=2026 (optional, defaults to current year)
|
|
||||||
*/
|
|
||||||
export const getLeaveHistory = async (ctx: Context) => {
|
|
||||||
const user = ctx.state.user;
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
|
|
||||||
let targetEmployeeId = user.employee_id;
|
|
||||||
if ((user.role === AppRole.ADMIN || user.role === AppRole.SUPER_ADMIN || user.role === AppRole.MANAGER) && params.get("employee_id")) {
|
|
||||||
targetEmployeeId = Number(params.get("employee_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = Number(params.get("year")) || new Date().getFullYear();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT
|
|
||||||
la.application_id,
|
|
||||||
lt.name AS leave_type_name,
|
|
||||||
DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from,
|
|
||||||
DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to,
|
|
||||||
la.number_of_days,
|
|
||||||
la.status,
|
|
||||||
la.reason
|
|
||||||
FROM leave_applications la
|
|
||||||
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.employee_id = ? AND YEAR(la.date_from) = ?
|
|
||||||
ORDER BY la.date_from DESC`,
|
|
||||||
[targetEmployeeId, year]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
employee_id: targetEmployeeId,
|
|
||||||
year: year,
|
|
||||||
count: rows.length,
|
|
||||||
history: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch leave history:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EMPLOYEE: Fetch upcoming optional holidays
|
|
||||||
*/
|
|
||||||
// export const getValidOptionalHolidays = async (ctx: Context) => {
|
|
||||||
// const user = ctx.state.user;
|
|
||||||
// const currentYear = new Date().getFullYear();
|
|
||||||
// const today = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// // Fetch optional holidays for the employee's company/branch that are today or in the future
|
|
||||||
// const [rows]: any = await db.execute(
|
|
||||||
// `SELECT
|
|
||||||
// holiday_id,
|
|
||||||
// holiday_name,
|
|
||||||
// DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
|
|
||||||
// FROM company_holidays
|
|
||||||
// WHERE company_id = ?
|
|
||||||
// AND calendar_year = ?
|
|
||||||
// AND holiday_type = 'OPTIONAL'
|
|
||||||
// AND (branch_id = ? OR branch_id IS NULL)
|
|
||||||
// AND holiday_date >= ?
|
|
||||||
// ORDER BY holiday_date ASC`,
|
|
||||||
// [user.company_id, currentYear, user.branch_id, today] // Assuming auth.ts injects company_id and branch_id
|
|
||||||
// );
|
|
||||||
|
|
||||||
// ctx.response.status = 200;
|
|
||||||
// ctx.response.body = {
|
|
||||||
// success: true,
|
|
||||||
// count: rows.length,
|
|
||||||
// holidays: rows
|
|
||||||
// };
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Failed to fetch optional holidays:", error);
|
|
||||||
// ctx.response.status = 500;
|
|
||||||
// ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EMPLOYEE: Fetch upcoming optional holidays
|
|
||||||
*/
|
|
||||||
export const getValidOptionalHolidays = async (ctx: Context) => {
|
|
||||||
const user = ctx.state.user;
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// FIX: Fallback to null if undefined to prevent MySQL driver crashes
|
|
||||||
const companyId = user.company_id ?? null;
|
|
||||||
const branchId = user.branch_id ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch optional holidays for the employee's company/branch that are today or in the future
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT
|
|
||||||
holiday_id,
|
|
||||||
holiday_name,
|
|
||||||
DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
|
|
||||||
FROM company_holidays
|
|
||||||
WHERE company_id = ?
|
|
||||||
AND calendar_year = ?
|
|
||||||
AND holiday_type = 'OPTIONAL'
|
|
||||||
AND (branch_id = ? OR branch_id IS NULL)
|
|
||||||
AND holiday_date >= ?
|
|
||||||
ORDER BY holiday_date ASC`,
|
|
||||||
[companyId, currentYear, branchId, today]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: rows.length,
|
|
||||||
holidays: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch optional holidays:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
// lms-service/controllers/leave.controller.ts
|
|
||||||
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
import { calculateLeaveDays } from '../../shared/calendar.service.ts';
|
|
||||||
|
|
||||||
const db = getDbPool("hrms_lms");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload interface expected from the Frontend UI
|
|
||||||
*/
|
|
||||||
export interface LeaveApplicationPayload {
|
|
||||||
employee_id: number;
|
|
||||||
leave_type_id: number;
|
|
||||||
company_id: number;
|
|
||||||
branch_id: number; // Used for fetching specific holiday/work settings
|
|
||||||
date_from: string; // YYYY-MM-DD
|
|
||||||
date_to: string; // YYYY-MM-DD
|
|
||||||
is_half_day: boolean;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applyForLeave = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Missing request body" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: LeaveApplicationPayload = await ctx.request.body.json();
|
|
||||||
const currentYear = new Date(payload.date_from).getFullYear();
|
|
||||||
const currentMonth = new Date(payload.date_from).getMonth() + 1; // 1-12
|
|
||||||
|
|
||||||
// 1. Fetch Configuration & Rules
|
|
||||||
const [branchSettingsRows]: any = await db.execute(
|
|
||||||
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
|
||||||
[payload.company_id, payload.branch_id]
|
|
||||||
);
|
|
||||||
const branchSettingsRaw = branchSettingsRows[0];
|
|
||||||
const workSettings = { weeklyOffDays: branchSettingsRaw?.weekly_off_days || [0] };
|
|
||||||
|
|
||||||
const [holidays]: any = await db.execute(
|
|
||||||
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
|
|
||||||
[payload.company_id, currentYear, payload.branch_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [policyRows]: any = await db.execute(
|
|
||||||
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
|
||||||
[payload.leave_type_id, payload.company_id, currentYear, payload.branch_id]
|
|
||||||
);
|
|
||||||
const policy = policyRows[0];
|
|
||||||
|
|
||||||
if (!policy) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Leave policy not configured for this type/year." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Calculate Requested Days using Shared Service
|
|
||||||
let requestedDays = 0;
|
|
||||||
if (payload.is_half_day) {
|
|
||||||
if (payload.date_from !== payload.date_to) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Half-day leaves must be on the same date." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestedDays = 0.5;
|
|
||||||
} else {
|
|
||||||
requestedDays = calculateLeaveDays(
|
|
||||||
payload.date_from,
|
|
||||||
payload.date_to,
|
|
||||||
workSettings,
|
|
||||||
holidays,
|
|
||||||
policy.apply_sandwich_policy
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedDays === 0) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { error: "Selected dates fall entirely on weekends/holidays with no sandwich policy applied." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch Balances & Monthly Limits
|
|
||||||
// FIX: Fetch raw balance first
|
|
||||||
const [balanceRows]: any = await db.execute(
|
|
||||||
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
|
|
||||||
[payload.employee_id, payload.leave_type_id, currentYear]
|
|
||||||
);
|
|
||||||
const rawBalance = Number(balanceRows[0]?.raw_balance || 0);
|
|
||||||
|
|
||||||
// FIX: Fetch pending leaves to prevent over-application
|
|
||||||
const [pendingRows]: any = await db.execute(
|
|
||||||
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
|
||||||
[payload.employee_id, payload.leave_type_id, currentYear]
|
|
||||||
);
|
|
||||||
const pendingDays = Number(pendingRows[0]?.pending_days || 0);
|
|
||||||
|
|
||||||
// Calculate actual available balance
|
|
||||||
const availableBalance = rawBalance - pendingDays;
|
|
||||||
|
|
||||||
// Check how many days the employee has already taken this month for this leave type
|
|
||||||
const [usageRows]: any = await db.execute(
|
|
||||||
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
|
|
||||||
[payload.employee_id, payload.leave_type_id, currentMonth, currentYear]
|
|
||||||
);
|
|
||||||
const usedThisMonth = Number(usageRows[0]?.used_this_month || 0);
|
|
||||||
|
|
||||||
// 4. Determine Paid vs LOP Split
|
|
||||||
let paidDays = 0;
|
|
||||||
let lopDays = 0;
|
|
||||||
|
|
||||||
const remainingMonthlyLimit = policy.max_days_per_month !== null
|
|
||||||
? Math.max(0, policy.max_days_per_month - usedThisMonth)
|
|
||||||
: requestedDays; // If no limit, they can take all requests if balance permits
|
|
||||||
|
|
||||||
const maxAllowedPaid = Math.min(availableBalance, remainingMonthlyLimit);
|
|
||||||
|
|
||||||
if (requestedDays <= maxAllowedPaid) {
|
|
||||||
paidDays = requestedDays;
|
|
||||||
} else {
|
|
||||||
paidDays = maxAllowedPaid;
|
|
||||||
lopDays = requestedDays - paidDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Fetch Manager ID via Cross-Database Query (Fixes hardcoded HTTP call)
|
|
||||||
const [managerRows]: any = await db.execute(
|
|
||||||
`SELECT c.reporting_to_id
|
|
||||||
FROM hrms_ems.employees e
|
|
||||||
JOIN hrms_ems.contracts c ON e.employee_id = c.employee_id
|
|
||||||
WHERE e.employee_id = ? AND c.status = 'ACTIVE'`,
|
|
||||||
[payload.employee_id]
|
|
||||||
);
|
|
||||||
const managerId = managerRows[0]?.reporting_to_id || null;
|
|
||||||
|
|
||||||
// 6. Execute Database Transaction for Auto-Split
|
|
||||||
const connection = await db.getConnection();
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const insertedIds = [];
|
|
||||||
|
|
||||||
// Insert Paid Record
|
|
||||||
if (paidDays > 0) {
|
|
||||||
const [result]: any = await connection.execute(
|
|
||||||
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?)`,
|
|
||||||
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, paidDays, payload.reason, managerId]
|
|
||||||
);
|
|
||||||
insertedIds.push(result.insertId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert LOP Record (Auto-Split)
|
|
||||||
if (lopDays > 0) {
|
|
||||||
const [result]: any = await connection.execute(
|
|
||||||
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING_LOP', ?)`,
|
|
||||||
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, lopDays, payload.reason, managerId]
|
|
||||||
);
|
|
||||||
insertedIds.push(result.insertId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = {
|
|
||||||
message: "Leave application submitted successfully.",
|
|
||||||
total_requested: requestedDays,
|
|
||||||
paid_days: paidDays,
|
|
||||||
lop_days: lopDays,
|
|
||||||
application_ids: insertedIds
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (dbError) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw dbError;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
||||||
ctx.response.body = { error: "Internal Server Error", details: errorMessage };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
// lms-service/controllers/manager.controller.ts
|
|
||||||
|
|
||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
|
|
||||||
const db = getDbPool("hrms_lms");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MANAGER: Fetch all pending leave applications for subordinates
|
|
||||||
*/
|
|
||||||
export const getPendingManagerLeaves = async (ctx: Context) => {
|
|
||||||
const user = ctx.state.user; // Logged-in Manager's data from auth.ts
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Cross-database join to get applicant details from EMS
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT
|
|
||||||
la.application_id,
|
|
||||||
la.employee_id,
|
|
||||||
la.leave_type_id,
|
|
||||||
lt.name AS leave_type,
|
|
||||||
la.date_from,
|
|
||||||
la.date_to,
|
|
||||||
la.number_of_days,
|
|
||||||
la.reason,
|
|
||||||
la.status,
|
|
||||||
e.employee_code,
|
|
||||||
p.first_name,
|
|
||||||
p.last_name
|
|
||||||
FROM leave_applications la
|
|
||||||
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
|
|
||||||
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
|
||||||
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.manager_approved_by = ?
|
|
||||||
AND la.status IN ('PENDING', 'PENDING_LOP')
|
|
||||||
ORDER BY la.date_from ASC`,
|
|
||||||
[user.employee_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
count: rows.length,
|
|
||||||
data: rows
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch manager pending leaves:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MANAGER: Approve a leave request (Triggers Ledger DEBIT & Balance Update)
|
|
||||||
*/
|
|
||||||
export const approveLeaveManager = async (ctx: Context) => {
|
|
||||||
const applicationId = ctx.params.applicationId;
|
|
||||||
const user = ctx.state.user; // The Manager approving it
|
|
||||||
const connection = await db.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
|
|
||||||
// 1. Lock the leave application row for update to prevent race conditions
|
|
||||||
const [leaveRows]: any = await connection.execute(
|
|
||||||
`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`,
|
|
||||||
[applicationId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (leaveRows.length === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Leave application not found." };
|
|
||||||
await connection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leave = leaveRows[0];
|
|
||||||
|
|
||||||
// Security: Ensure this manager is actually assigned to this leave
|
|
||||||
if (leave.manager_approved_by !== user.employee_id) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { success: false, message: "Access Denied: You are not the assigned manager for this leave." };
|
|
||||||
await connection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent double-approval
|
|
||||||
if (!['PENDING', 'PENDING_LOP'].includes(leave.status)) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: `Leave is already processed. Current status: ${leave.status}` };
|
|
||||||
await connection.rollback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Determine new status
|
|
||||||
const newStatus = leave.status === 'PENDING_LOP' ? 'APPROVED_LOP' : 'APPROVED';
|
|
||||||
|
|
||||||
// 3. Update Leave Application Status
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE leave_applications SET status = ? WHERE application_id = ?`,
|
|
||||||
[newStatus, applicationId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Ledger & Balance Updates (Only for Paid Leaves, skip for LOP)
|
|
||||||
if (newStatus === 'APPROVED') {
|
|
||||||
const currentYear = new Date(leave.date_from).getFullYear();
|
|
||||||
const currentMonth = new Date(leave.date_from).getMonth() + 1;
|
|
||||||
|
|
||||||
// Fetch current balance to calculate Opening Balance (OB) for the ledger
|
|
||||||
const [allocRows]: any = await connection.execute(
|
|
||||||
`SELECT granted_days, used_days FROM leave_allocations
|
|
||||||
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
|
|
||||||
[leave.employee_id, leave.leave_type_id, currentYear]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allocRows.length > 0) {
|
|
||||||
const alloc = allocRows[0];
|
|
||||||
const openingBalance = Number(alloc.granted_days) - Number(alloc.used_days);
|
|
||||||
const closingBalance = openingBalance - Number(leave.number_of_days);
|
|
||||||
|
|
||||||
// A. Write to Immutable Ledger (The Excel OB/CB replacement)
|
|
||||||
await connection.execute(
|
|
||||||
`INSERT INTO leave_ledger_transactions
|
|
||||||
(employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
leave.employee_id,
|
|
||||||
leave.leave_type_id,
|
|
||||||
applicationId,
|
|
||||||
currentYear,
|
|
||||||
currentMonth,
|
|
||||||
leave.number_of_days,
|
|
||||||
openingBalance,
|
|
||||||
closingBalance,
|
|
||||||
`Approved by Manager ID: ${user.employee_id}`
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// B. Update the Allocations Table (Speeds up frontend dashboard loads)
|
|
||||||
await connection.execute(
|
|
||||||
`UPDATE leave_allocations SET used_days = used_days + ?
|
|
||||||
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`,
|
|
||||||
[leave.number_of_days, leave.employee_id, leave.leave_type_id, currentYear]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Leave approved successfully.",
|
|
||||||
new_status: newStatus
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
console.error("Manager approval failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MANAGER: Reject a leave request
|
|
||||||
*/
|
|
||||||
export const rejectLeaveManager = async (ctx: Context) => {
|
|
||||||
const applicationId = ctx.params.applicationId;
|
|
||||||
const user = ctx.state.user;
|
|
||||||
|
|
||||||
if (!ctx.request.hasBody) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing rejection reason in body." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const rejectionReason = body.reason;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result]: any = await db.execute(
|
|
||||||
`UPDATE leave_applications
|
|
||||||
SET status = 'REJECTED'
|
|
||||||
WHERE application_id = ? AND manager_approved_by = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
|
||||||
[applicationId, user.employee_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.affectedRows === 0) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, message: "Leave not found, already processed, or you lack permission." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: You could log this rejection in an audit table or leave_remarks table here
|
|
||||||
// using the `rejectionReason` variable.
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
message: "Leave rejected successfully."
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Manager rejection failed:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
// lms-service/controllers/reports.controller.ts
|
|
||||||
|
|
||||||
import { Context } from "@oak/oak";
|
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
|
||||||
|
|
||||||
const db = getDbPool("hrms_lms");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ADMIN: Generate the Excel-style OB/CB Report for a specific month
|
|
||||||
* Query: ?month=5&year=2026&company_id=1
|
|
||||||
*/
|
|
||||||
export const getLedgerReport = async (ctx: Context) => {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const month = Number(params.get("month"));
|
|
||||||
const year = Number(params.get("year"));
|
|
||||||
const companyId = Number(params.get("company_id"));
|
|
||||||
|
|
||||||
if (!month || !year || !companyId) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, message: "Missing required query params: month, year, company_id" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// This query ensures every employee is listed, even if they had 0 transactions that month.
|
|
||||||
// It fetches the closing balance of the LAST transaction BEFORE the month started (OB)
|
|
||||||
// And the closing balance of the LAST transaction UP TO the END of the month (CB).
|
|
||||||
const [rows]: any = await db.execute(
|
|
||||||
`SELECT
|
|
||||||
e.employee_id,
|
|
||||||
e.employee_code,
|
|
||||||
CONCAT(p.first_name, ' ', p.last_name) AS employee_name,
|
|
||||||
lt.name AS leave_type,
|
|
||||||
la.granted_days AS total_granted,
|
|
||||||
la.used_days AS total_used,
|
|
||||||
|
|
||||||
-- Opening Balance: Closing balance of the last transaction BEFORE the target month
|
|
||||||
COALESCE((
|
|
||||||
SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob
|
|
||||||
WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id
|
|
||||||
AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?))
|
|
||||||
ORDER BY t_ob.transaction_date DESC LIMIT 1
|
|
||||||
), la.granted_days) AS opening_balance,
|
|
||||||
|
|
||||||
-- Closing Balance: Closing balance of the last transaction UP TO the END of the target month
|
|
||||||
COALESCE((
|
|
||||||
SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb
|
|
||||||
WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id
|
|
||||||
AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?))
|
|
||||||
ORDER BY t_cb.transaction_date DESC LIMIT 1
|
|
||||||
), la.granted_days) AS closing_balance
|
|
||||||
|
|
||||||
FROM leave_allocations la
|
|
||||||
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
|
|
||||||
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
|
||||||
WHERE la.calendar_year = ? AND la.company_id = ?`,
|
|
||||||
[year, year, month, year, year, month, year, companyId]
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.response.status = 200;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: true,
|
|
||||||
report_month: month,
|
|
||||||
report_year: year,
|
|
||||||
count: rows.length,
|
|
||||||
data: rows
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to generate OB/CB report:", error);
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { Application } from "@oak/oak";
|
|
||||||
import router from "./routes.ts";
|
|
||||||
|
|
||||||
const app = new Application();
|
|
||||||
const PORT = 8003; // Running LMS on 8003 to avoid conflict with EMS on 8001 and AMS on 8002
|
|
||||||
|
|
||||||
app.use(async (ctx, next) => {
|
|
||||||
console.log(`[LMS] ${ctx.request.method} ${ctx.request.url.pathname}`);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(router.routes());
|
|
||||||
app.use(router.allowedMethods());
|
|
||||||
|
|
||||||
console.log(`LMS Service running on http://localhost:${PORT}`);
|
|
||||||
await app.listen({ port: PORT });
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
// lms-service/routes.ts
|
|
||||||
import { Router } from "@oak/oak";
|
|
||||||
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
|
||||||
import { applyForLeave } from "./controllers/leave.controller.ts";
|
|
||||||
import {
|
|
||||||
getPendingManagerLeaves,
|
|
||||||
approveLeaveManager,
|
|
||||||
rejectLeaveManager
|
|
||||||
} from "./controllers/manager.controller.ts";
|
|
||||||
import {
|
|
||||||
getLeaveBalances,
|
|
||||||
getLeaveHistory,
|
|
||||||
getValidOptionalHolidays
|
|
||||||
} from "./controllers/employee.controller.ts";
|
|
||||||
import {
|
|
||||||
createLeaveType,
|
|
||||||
getLeaveTypes,
|
|
||||||
createPolicyRule,
|
|
||||||
createCompanyHoliday,
|
|
||||||
updateWorkSettings
|
|
||||||
} from "./controllers/admin.controller.ts";
|
|
||||||
import { getLedgerReport } from "./controllers/reports.controller.ts";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
const apiV1 = new Router();
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 1. Employee Leave Actions (All Staff)
|
|
||||||
// ==========================================
|
|
||||||
// Core application endpoint (Handles auto-splits, LOP, and sandwich logic)
|
|
||||||
apiV1.post("/lms/leaves/apply", requireAuth, applyForLeave);
|
|
||||||
|
|
||||||
// Employee dashboard endpoints
|
|
||||||
apiV1.get("/lms/leaves/balances", requireAuth, getLeaveBalances);
|
|
||||||
apiV1.get("/lms/leaves/history", requireAuth, getLeaveHistory);
|
|
||||||
apiV1.get("/lms/holidays/valid-optional", requireAuth, getValidOptionalHolidays);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 2. Manager Actions
|
|
||||||
// ==========================================
|
|
||||||
apiV1.get("/lms/manager/pending", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingManagerLeaves);
|
|
||||||
apiV1.post("/lms/manager/leaves/:applicationId/approve", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), approveLeaveManager);
|
|
||||||
apiV1.post("/lms/manager/leaves/:applicationId/reject", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), rejectLeaveManager);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 3. Admin Setup & Configuration
|
|
||||||
// ==========================================
|
|
||||||
apiV1.post("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createLeaveType);
|
|
||||||
apiV1.get("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLeaveTypes);
|
|
||||||
apiV1.post("/lms/config/rules", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createPolicyRule);
|
|
||||||
apiV1.post("/lms/config/holidays", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createCompanyHoliday);
|
|
||||||
apiV1.put("/lms/config/work-settings", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), updateWorkSettings);
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 4. Admin / HR Reporting
|
|
||||||
// ==========================================
|
|
||||||
// Upcoming endpoints to build:
|
|
||||||
apiV1.get("/lms/admin/reports/ob-cb", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLedgerReport);
|
|
||||||
// router.post("/api/v1/lms/admin/leaves/:applicationId/approve", approveLeaveAdmin);
|
|
||||||
|
|
||||||
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
|
||||||
export default router;
|
|
||||||
66
packages/core/auth.ts
Normal file
66
packages/core/auth.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// packages/core/auth.ts
|
||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
|
||||||
|
export enum AppRole {
|
||||||
|
DIRECTOR = "DIRECTOR",
|
||||||
|
HR_MANAGER = "HR_MANAGER",
|
||||||
|
MANAGER = "MANAGER",
|
||||||
|
EMPLOYEE = "EMPLOYEE",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authentication & Internal Security Middleware
|
||||||
|
export const requireAuth = async (ctx: Context, next: Next) => {
|
||||||
|
const internalToken = ctx.request.headers.get("X-Internal-Token");
|
||||||
|
const expectedToken = Deno.env.get("INTERNAL_SERVICE_TOKEN");
|
||||||
|
|
||||||
|
if (!expectedToken || internalToken !== expectedToken) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = { success: false, message: "Forbidden: Direct access to microservices is not allowed." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = ctx.request.headers.get("X-User-Id");
|
||||||
|
const userRoleHeader = ctx.request.headers.get("X-User-Role") as string;
|
||||||
|
const managedBranchesHeader = ctx.request.headers.get("X-Managed-Branches");
|
||||||
|
|
||||||
|
if (!userId || !userRoleHeader) {
|
||||||
|
ctx.response.status = 401;
|
||||||
|
ctx.response.body = { success: false, message: "Unauthorized: Missing user context" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map string header to Enum (with fallback for backward compatibility)
|
||||||
|
let userRole: AppRole;
|
||||||
|
if (userRoleHeader === "DIRECTOR" || userRoleHeader === "SUPER_ADMIN") userRole = AppRole.DIRECTOR;
|
||||||
|
else if (userRoleHeader === "HR_MANAGER" || userRoleHeader === "ADMIN") userRole = AppRole.HR_MANAGER;
|
||||||
|
else if (userRoleHeader === "MANAGER") userRole = AppRole.MANAGER;
|
||||||
|
else userRole = AppRole.EMPLOYEE;
|
||||||
|
|
||||||
|
const managedBranches = managedBranchesHeader
|
||||||
|
? managedBranchesHeader.split(",").map(Number).filter(n => !isNaN(n))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
ctx.state.user = {
|
||||||
|
employee_id: Number(userId),
|
||||||
|
role: userRole,
|
||||||
|
managed_branches: managedBranches
|
||||||
|
};
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Authorization Middleware
|
||||||
|
export const requireRole = (allowedRoles: AppRole[]) => {
|
||||||
|
return async (ctx: Context, next: Next) => {
|
||||||
|
const userRole = ctx.state.user?.role as AppRole;
|
||||||
|
if (!userRole || !allowedRoles.includes(userRole)) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Access Denied: You do not have the required permissions."
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
};
|
||||||
9
packages/core/deno.json
Normal file
9
packages/core/deno.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@shared/utils",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"exports": {
|
||||||
|
"./auth": "./auth.ts",
|
||||||
|
"./internal-client": "./internal-client.ts",
|
||||||
|
"./calendar": "./calendar.service.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/core/internal-client.ts
Normal file
42
packages/core/internal-client.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// packages/core/internal-client.ts
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import { InternalServiceClient } from "../../generated/ems.ts";
|
||||||
|
|
||||||
|
const EMS_URL = Deno.env.get("EMS_URL") || "localhost:8001";
|
||||||
|
const internalClient = new InternalServiceClient(EMS_URL, grpc.credentials.createInsecure());
|
||||||
|
|
||||||
|
// Helper to promisify gRPC calls
|
||||||
|
const callGrpc = (method: string, payload: any) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
(internalClient as any)[method](payload, (err: any, response: any) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the manager ID for a specific employee from EMS via gRPC.
|
||||||
|
*/
|
||||||
|
export const getEmployeeManagerId = async (employeeId: number): Promise<number | null> => {
|
||||||
|
try {
|
||||||
|
const response: any = await callGrpc("getManager", { id: employeeId });
|
||||||
|
return response.managerId || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch manager from EMS via gRPC:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a roster of active employees from EMS via gRPC.
|
||||||
|
*/
|
||||||
|
export const getEmployeeRoster = async (ids: number[] = []): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response: any = await callGrpc("getRoster", { ids });
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch roster from EMS via gRPC:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
287
protos/ams.proto
Normal file
287
protos/ams.proto
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package ams;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SERVICES
|
||||||
|
// ==========================================
|
||||||
|
service AttendanceService {
|
||||||
|
rpc GetRawLogs(Empty) returns (RawLogsResponse);
|
||||||
|
rpc ProcessDailyAttendance(ProcessAttendanceRequest) returns (SuccessResponse);
|
||||||
|
rpc GetEmployeeSummary(SummaryRequest) returns (SummaryResponse);
|
||||||
|
rpc GetDailyReport(DailyReportRequest) returns (DailyReportResponse);
|
||||||
|
rpc GetAdminRangeReport(RangeReportRequest) returns (AdminRangeReportResponse);
|
||||||
|
rpc GetSingleEmployeeRangeReport(SingleRangeReportRequest) returns (SingleRangeReportResponse);
|
||||||
|
rpc ExportDailyReport(DailyReportRequest) returns (ExportReportResponse);
|
||||||
|
rpc ExportAdminRangeReport(RangeReportRequest) returns (ExportReportResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service RegularizationService {
|
||||||
|
rpc CreateRegularizationRequest(CreateRegRequest) returns (SuccessResponse);
|
||||||
|
rpc GetPendingRegularizations(PendingRegRequest) returns (PendingRegResponse);
|
||||||
|
rpc ReviewRegularizationRequest(ReviewRegRequest) returns (SuccessResponse);
|
||||||
|
rpc GetAllRegularizations(Empty) returns (AllRegularizationsResponse); // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
service DashboardService {
|
||||||
|
rpc GetDashboardMetrics(DashboardRequest) returns (DashboardMetricsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMMON MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message Empty {}
|
||||||
|
|
||||||
|
message SuccessResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ATTENDANCE MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message ProcessAttendanceRequest {
|
||||||
|
optional string work_date = 1;
|
||||||
|
optional string start_date = 2;
|
||||||
|
optional string end_date = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawLog {
|
||||||
|
int32 id = 1;
|
||||||
|
string attendance_time = 2;
|
||||||
|
string device_id = 3;
|
||||||
|
string employee_code = 4;
|
||||||
|
string employee_name = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RawLogsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated RawLog data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SummaryRequest {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string start_date = 2;
|
||||||
|
string end_date = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SummaryMetrics {
|
||||||
|
int32 total_days_tracked = 1;
|
||||||
|
double total_hours_worked = 2;
|
||||||
|
int32 full_days = 3;
|
||||||
|
int32 half_days = 4;
|
||||||
|
int32 absences = 5;
|
||||||
|
int32 mispunches = 6;
|
||||||
|
int32 late_arrivals = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AttendanceRecord {
|
||||||
|
int32 attendance_id = 1;
|
||||||
|
string work_date = 2;
|
||||||
|
string check_in = 3;
|
||||||
|
string check_out = 4;
|
||||||
|
double worked_hours = 5;
|
||||||
|
string check_in_status = 6;
|
||||||
|
string final_status = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SummaryResponse {
|
||||||
|
bool success = 1;
|
||||||
|
SummaryMetrics metrics = 2;
|
||||||
|
repeated AttendanceRecord history = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DAILY REPORT ---
|
||||||
|
message DailyReportRequest {
|
||||||
|
string date = 1;
|
||||||
|
optional int32 company_id = 2;
|
||||||
|
optional int32 branch_id = 3;
|
||||||
|
optional int32 department_id = 4;
|
||||||
|
optional string designation = 5; // Job Title
|
||||||
|
optional string employee_name = 6;
|
||||||
|
optional string employee_code = 7;
|
||||||
|
string file_type = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DailySummary {
|
||||||
|
int32 total_active_workforce = 1;
|
||||||
|
int32 present = 2;
|
||||||
|
int32 late = 3;
|
||||||
|
int32 mispunches = 4;
|
||||||
|
int32 absent = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DailyRosterEmployee {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
string full_name = 3;
|
||||||
|
optional int32 attendance_id = 4;
|
||||||
|
string status = 5;
|
||||||
|
optional string check_in = 6;
|
||||||
|
optional string check_out = 7;
|
||||||
|
string worked_hours = 8;
|
||||||
|
string punctuality = 9;
|
||||||
|
string company_name = 10; // NEW
|
||||||
|
string branch_name = 11; // NEW
|
||||||
|
string department_name = 12; // NEW
|
||||||
|
string designation = 13; // NEW (Job Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
message DailyReportResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string date = 2;
|
||||||
|
DailySummary summary = 3;
|
||||||
|
repeated DailyRosterEmployee roster = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ADMIN RANGE REPORT ---
|
||||||
|
message RangeReportRequest {
|
||||||
|
string from_date = 1;
|
||||||
|
string to_date = 2;
|
||||||
|
optional int32 branch_id = 3;
|
||||||
|
optional int32 department_id = 4;
|
||||||
|
optional int32 company_id = 5;
|
||||||
|
optional string designation = 6;
|
||||||
|
optional string employee_name = 7;
|
||||||
|
optional string employee_code = 8;
|
||||||
|
string file_type = 9; // <--- ADD THIS LINE
|
||||||
|
}
|
||||||
|
|
||||||
|
message RangeMetrics {
|
||||||
|
int32 full_days = 1;
|
||||||
|
int32 half_days = 2;
|
||||||
|
int32 mispunches = 3;
|
||||||
|
int32 late_arrivals = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GlobalSummary {
|
||||||
|
int32 total_records_evaluated = 1;
|
||||||
|
int32 full_days = 2;
|
||||||
|
int32 half_days = 3;
|
||||||
|
int32 late_instances = 4;
|
||||||
|
int32 mispunches = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminRangeEmployee {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
string first_name = 3;
|
||||||
|
string last_name = 4;
|
||||||
|
string branch_name = 5;
|
||||||
|
string department_name = 6;
|
||||||
|
string company_name = 7; // NEW
|
||||||
|
string designation = 8; // NEW
|
||||||
|
RangeMetrics range_metrics = 9;
|
||||||
|
repeated AttendanceRecord attendance_history = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdminRangeReportResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string from_date = 2;
|
||||||
|
string to_date = 3;
|
||||||
|
GlobalSummary global_summary = 4;
|
||||||
|
repeated AdminRangeEmployee report = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SINGLE EMPLOYEE RANGE REPORT ---
|
||||||
|
message SingleRangeReportRequest {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string from_date = 2;
|
||||||
|
string to_date = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SingleRangeEmployee {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
string first_name = 3;
|
||||||
|
string last_name = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SingleRangeReportResponse {
|
||||||
|
bool success = 1;
|
||||||
|
SingleRangeEmployee employee = 2;
|
||||||
|
string from_date = 3;
|
||||||
|
string to_date = 4;
|
||||||
|
int32 total_days = 5;
|
||||||
|
repeated AttendanceRecord history = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message DashboardRequest {
|
||||||
|
string date = 1;
|
||||||
|
optional int32 company_id = 2;
|
||||||
|
optional int32 branch_id = 3;
|
||||||
|
optional int32 department_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DashboardMetricsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 total_employees = 2;
|
||||||
|
int32 total_checked_in = 3;
|
||||||
|
int32 total_not_checked_in = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REGULARIZATION MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message CreateRegRequest {
|
||||||
|
optional int32 attendance_id = 1;
|
||||||
|
int32 employee_id = 2;
|
||||||
|
string regularization_type = 3;
|
||||||
|
string target_date = 4;
|
||||||
|
optional string requested_check_in = 5;
|
||||||
|
optional string requested_check_out = 6;
|
||||||
|
string reason = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PendingRegRequest {
|
||||||
|
string user_role = 1;
|
||||||
|
repeated int32 managed_branch_ids = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PendingRegData {
|
||||||
|
int32 regularization_id = 1;
|
||||||
|
int32 employee_id = 2;
|
||||||
|
string target_date = 3;
|
||||||
|
string regularization_type = 4;
|
||||||
|
optional string requested_check_in = 5;
|
||||||
|
optional string requested_check_out = 6;
|
||||||
|
string reason = 7;
|
||||||
|
string status = 8;
|
||||||
|
int32 branch_id = 9;
|
||||||
|
string employee_code = 10;
|
||||||
|
string first_name = 11;
|
||||||
|
string last_name = 12;
|
||||||
|
string branch_name = 13;
|
||||||
|
string reviewer_name = 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PendingRegResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated PendingRegData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReviewRegRequest {
|
||||||
|
int32 regularization_id = 1;
|
||||||
|
string action = 2;
|
||||||
|
int32 reviewer_id = 3;
|
||||||
|
string user_role = 4;
|
||||||
|
repeated int32 managed_branch_ids = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AllRegularizationsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated PendingRegData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to common messages
|
||||||
|
message ExportReportResponse {
|
||||||
|
bytes file_content = 1;
|
||||||
|
string file_name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
414
protos/ems.proto
Normal file
414
protos/ems.proto
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package ems;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SERVICES
|
||||||
|
// ==========================================
|
||||||
|
service EmployeeService {
|
||||||
|
rpc GetEmployees(GetEmployeesRequest) returns (EmployeesListResponse);
|
||||||
|
rpc GetEmployeeById(IdRequest) returns (EmployeeDetailResponse);
|
||||||
|
rpc GetEmployeeHistory(IdRequest) returns (EmployeeHistoryResponse);
|
||||||
|
rpc CreateEmployee(CreateEmployeeRequest) returns (CreateEmployeeResponse);
|
||||||
|
rpc UpdateEmployee(UpdateEmployeeRequest) returns (SuccessResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service InternalService {
|
||||||
|
rpc GetRoster(RosterRequest) returns (RosterResponse);
|
||||||
|
rpc GetManager(IdRequest) returns (ManagerResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service ContractService {
|
||||||
|
rpc GetEmployeeContracts(IdRequest) returns (ContractsResponse);
|
||||||
|
rpc CreateContract(CreateContractRequest) returns (ContractCreateResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service DashboardService {
|
||||||
|
rpc GetDashboardMetrics(DashboardRequest) returns (DashboardMetricsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service LookupService {
|
||||||
|
rpc GetCompanies(LookupRequest) returns (CompaniesResponse);
|
||||||
|
rpc CreateCompany(CompanyData) returns (LookupCreateResponse);
|
||||||
|
rpc UpdateCompany(UpdateLookupRequest) returns (SuccessResponse);
|
||||||
|
rpc DeleteCompany(IdRequest) returns (SuccessResponse);
|
||||||
|
|
||||||
|
rpc GetBranches(LookupRequest) returns (BranchesResponse);
|
||||||
|
rpc CreateBranch(BranchData) returns (LookupCreateResponse);
|
||||||
|
rpc UpdateBranch(UpdateLookupRequest) returns (SuccessResponse);
|
||||||
|
rpc DeleteBranch(IdRequest) returns (SuccessResponse);
|
||||||
|
|
||||||
|
rpc GetDepartments(LookupRequest) returns (DepartmentsResponse);
|
||||||
|
rpc CreateDepartment(DepartmentData) returns (LookupCreateResponse);
|
||||||
|
rpc UpdateDepartment(UpdateLookupRequest) returns (SuccessResponse);
|
||||||
|
rpc DeleteDepartment(IdRequest) returns (SuccessResponse);
|
||||||
|
|
||||||
|
rpc GetJobs(LookupRequest) returns (JobsResponse);
|
||||||
|
rpc CreateJob(JobData) returns (LookupCreateResponse);
|
||||||
|
rpc UpdateJob(UpdateLookupRequest) returns (SuccessResponse);
|
||||||
|
rpc DeleteJob(IdRequest) returns (SuccessResponse);
|
||||||
|
|
||||||
|
rpc GetDepartmentManagers(DepartmentManagersRequest) returns (ManagersResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMMON MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message SuccessResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message IdRequest {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LookupRequest {
|
||||||
|
optional int32 company_id = 1;
|
||||||
|
optional int32 branch_id = 2;
|
||||||
|
optional int32 department_id = 3;
|
||||||
|
optional bool is_active = 4;
|
||||||
|
optional string sort_by = 5;
|
||||||
|
optional string sort_order = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LookupCreateResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
int32 id = 3;
|
||||||
|
string code = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateLookupRequest {
|
||||||
|
int32 id = 1;
|
||||||
|
CompanyData company_data = 2;
|
||||||
|
BranchData branch_data = 3;
|
||||||
|
DepartmentData department_data = 4;
|
||||||
|
JobData job_data = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EMPLOYEE MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message GetEmployeesRequest {
|
||||||
|
optional int32 company_id = 1;
|
||||||
|
optional int32 branch_id = 2;
|
||||||
|
optional int32 department_id = 3;
|
||||||
|
optional bool is_active = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeeSummary {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
bool is_active = 3;
|
||||||
|
string first_name = 4; // CHANGED from full_name
|
||||||
|
string last_name = 5; // NEW
|
||||||
|
string work_email = 6; // Renumbered
|
||||||
|
string job_name = 7;
|
||||||
|
string department_name = 8;
|
||||||
|
string branch_name = 9;
|
||||||
|
string company_name = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeesListResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 count = 2;
|
||||||
|
repeated EmployeeSummary data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Address {
|
||||||
|
string type = 1;
|
||||||
|
string door_number = 2;
|
||||||
|
string landmark = 3;
|
||||||
|
string line = 4;
|
||||||
|
string pincode = 5;
|
||||||
|
string district = 6;
|
||||||
|
string state = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeeDetail {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
bool is_active = 3;
|
||||||
|
string first_name = 4;
|
||||||
|
string last_name = 5;
|
||||||
|
string dob = 6;
|
||||||
|
string gender = 7;
|
||||||
|
string personal_email = 8;
|
||||||
|
string personal_phone = 9;
|
||||||
|
string work_email = 10;
|
||||||
|
string date_joining = 11;
|
||||||
|
int32 probation_days = 12;
|
||||||
|
string contract_status = 13;
|
||||||
|
int32 salary_structure_id = 14;
|
||||||
|
string department = 15;
|
||||||
|
int32 department_id = 16;
|
||||||
|
string job_name = 17; // RENAMED from designation
|
||||||
|
int32 job_id = 18;
|
||||||
|
string manager_first_name = 19;
|
||||||
|
string manager_last_name = 20;
|
||||||
|
string manager_employee_code = 21;
|
||||||
|
repeated Address addresses = 22;
|
||||||
|
string company_name = 23; // ADDED
|
||||||
|
int32 company_id = 24; // ADDED
|
||||||
|
string branch_name = 25; // ADDED
|
||||||
|
int32 branch_id = 26; // ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeeDetailResponse {
|
||||||
|
bool success = 1;
|
||||||
|
EmployeeDetail data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AssignmentHistory {
|
||||||
|
int32 assignment_id = 1;
|
||||||
|
string change_reason = 2;
|
||||||
|
string effective_from = 3;
|
||||||
|
string effective_to = 4;
|
||||||
|
bool is_current = 5;
|
||||||
|
string department = 6;
|
||||||
|
string job_title = 7;
|
||||||
|
string manager_first_name = 8;
|
||||||
|
string manager_last_name = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeeHistoryResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated AssignmentHistory history = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateEmployeeRequest {
|
||||||
|
string first_name = 1;
|
||||||
|
string last_name = 2;
|
||||||
|
string dob = 3;
|
||||||
|
string gender = 4;
|
||||||
|
string personal_email = 5;
|
||||||
|
string personal_phone = 6;
|
||||||
|
Address address = 7;
|
||||||
|
optional int32 company_id = 8;
|
||||||
|
optional int32 branch_id = 9;
|
||||||
|
optional int32 department_id = 10;
|
||||||
|
optional int32 job_id = 11;
|
||||||
|
string work_email = 12;
|
||||||
|
string date_joining = 13;
|
||||||
|
optional int32 probation_days = 14;
|
||||||
|
optional int32 salary_structure_id = 15;
|
||||||
|
optional int32 reporting_to_id = 16;
|
||||||
|
optional bool is_active = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateEmployeeResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
int32 employee_id = 3;
|
||||||
|
string employee_code = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateEmployeeRequest {
|
||||||
|
int32 id = 1;
|
||||||
|
CreateEmployeeRequest data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOOKUP MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message CompanyData {
|
||||||
|
string name = 1;
|
||||||
|
optional int32 parent_id = 2;
|
||||||
|
bool is_active = 3;
|
||||||
|
optional string company_code = 4; // Used for create
|
||||||
|
}
|
||||||
|
|
||||||
|
message Company {
|
||||||
|
int32 company_id = 1;
|
||||||
|
string company_code = 2;
|
||||||
|
string name = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompaniesResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated Company data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BranchData {
|
||||||
|
int32 company_id = 1;
|
||||||
|
string branch_name = 2;
|
||||||
|
string code = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Branch {
|
||||||
|
int32 branch_id = 1;
|
||||||
|
string code = 2;
|
||||||
|
string branch_name = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
string company_code = 5;
|
||||||
|
string company_name = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BranchesResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated Branch data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DepartmentData {
|
||||||
|
int32 company_id = 1;
|
||||||
|
int32 branch_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
optional int32 parent_id = 4;
|
||||||
|
optional int32 manager_id = 5;
|
||||||
|
bool is_active = 6;
|
||||||
|
optional string department_code = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Department {
|
||||||
|
int32 department_id = 1;
|
||||||
|
string department_code = 2;
|
||||||
|
string department_name = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
string branch_name = 5;
|
||||||
|
string company_name = 6;
|
||||||
|
string managers = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DepartmentsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated Department data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message JobData {
|
||||||
|
int32 department_id = 1;
|
||||||
|
string title = 2;
|
||||||
|
string description = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
optional string job_code = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Job {
|
||||||
|
int32 job_id = 1;
|
||||||
|
string job_code = 2;
|
||||||
|
string job_name = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
string department_name = 5;
|
||||||
|
string branch_name = 6;
|
||||||
|
string company_name = 7;
|
||||||
|
string description = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message JobsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated Job data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// INTERNAL SERVICE MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message RosterRequest {
|
||||||
|
repeated int32 ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RosterEmployee {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
bool is_active = 3;
|
||||||
|
string full_name = 4;
|
||||||
|
int32 branch_id = 5;
|
||||||
|
string branch_name = 6;
|
||||||
|
int32 company_id = 7;
|
||||||
|
string company_name = 8;
|
||||||
|
string department_name = 9;
|
||||||
|
int32 department_id = 10;
|
||||||
|
string job_title = 11;
|
||||||
|
string manager_name = 12;
|
||||||
|
string manager_id = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RosterResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated RosterEmployee data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ManagerResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 manager_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CONTRACT MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message Contract {
|
||||||
|
int32 term_id = 1;
|
||||||
|
string work_email = 2;
|
||||||
|
string date_joining = 3;
|
||||||
|
int32 probation_days = 4;
|
||||||
|
string status = 5;
|
||||||
|
int32 salary_structure_id = 6;
|
||||||
|
int32 assignment_id = 7;
|
||||||
|
string change_reason = 8;
|
||||||
|
string effective_from = 9;
|
||||||
|
string effective_to = 10;
|
||||||
|
bool is_current = 11;
|
||||||
|
string department = 12;
|
||||||
|
string designation = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContractsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 count = 2;
|
||||||
|
repeated Contract data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateContractRequest {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
int32 department_id = 2;
|
||||||
|
int32 job_id = 3;
|
||||||
|
optional int32 reporting_to_id = 4;
|
||||||
|
string change_reason = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContractCreateResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
int32 assignment_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message DashboardRequest {
|
||||||
|
optional int32 company_id = 1;
|
||||||
|
optional int32 branch_id = 2;
|
||||||
|
optional int32 department_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DashboardMetricsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 total_companies = 2;
|
||||||
|
int32 total_branches = 3;
|
||||||
|
int32 total_departments = 4;
|
||||||
|
int32 total_jobs = 5;
|
||||||
|
int32 total_employees = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MANAGER MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message ManagerData {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string employee_code = 2;
|
||||||
|
string first_name = 3;
|
||||||
|
string last_name = 4;
|
||||||
|
int32 department_id = 5;
|
||||||
|
string department_name = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ManagersResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated ManagerData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DepartmentManagersRequest {
|
||||||
|
int32 department_id = 1;
|
||||||
|
}
|
||||||
276
protos/lms.proto
Normal file
276
protos/lms.proto
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package lms;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SERVICES
|
||||||
|
// ==========================================
|
||||||
|
service AdminService {
|
||||||
|
rpc CreateLeaveType(LeaveTypeData) returns (CreateResponse);
|
||||||
|
rpc GetLeaveTypes(CompanyIdRequest) returns (LeaveTypesResponse);
|
||||||
|
rpc CreatePolicyRule(PolicyRuleData) returns (CreateResponse);
|
||||||
|
rpc CreateCompanyHoliday(HolidayList) returns (SuccessResponse);
|
||||||
|
rpc UpdateWorkSettings(WorkSettingsData) returns (SuccessResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service EmployeeService {
|
||||||
|
rpc GetLeaveBalances(EmployeeActionRequest) returns (BalancesResponse);
|
||||||
|
rpc GetLeaveHistory(EmployeeActionRequest) returns (HistoryResponse);
|
||||||
|
rpc GetValidOptionalHolidays(UserContextRequest) returns (HolidaysResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service LeaveService {
|
||||||
|
rpc ApplyForLeave(LeaveApplicationPayload) returns (ApplyLeaveResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service ManagerService {
|
||||||
|
rpc GetPendingManagerLeaves(UserContextRequest) returns (PendingLeavesResponse);
|
||||||
|
rpc ApproveLeaveManager(ManagerActionRequest) returns (SuccessResponse);
|
||||||
|
rpc RejectLeaveManager(ManagerActionRequest) returns (SuccessResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service ReportsService {
|
||||||
|
rpc GetLedgerReport(LedgerReportRequest) returns (LedgerReportResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMMON MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message SuccessResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
int32 id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompanyIdRequest {
|
||||||
|
int32 company_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserContextRequest {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string role = 2;
|
||||||
|
string managed_branches = 3; // Comma-separated string
|
||||||
|
optional int32 target_employee_id = 4; // Used when managers/admins query other employees
|
||||||
|
optional int32 year = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmployeeActionRequest {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
int32 year = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ADMIN MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message LeaveTypeData {
|
||||||
|
int32 company_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
bool requires_allocation = 3;
|
||||||
|
bool carry_over_allowed = 4;
|
||||||
|
double max_carry_over_days = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LeaveType {
|
||||||
|
int32 leave_type_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
bool requires_allocation = 3;
|
||||||
|
bool carry_over_allowed = 4;
|
||||||
|
double max_carry_over_days = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LeaveTypesResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated LeaveType data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PolicyRuleData {
|
||||||
|
int32 leave_type_id = 1;
|
||||||
|
int32 company_id = 2;
|
||||||
|
optional int32 branch_id = 3;
|
||||||
|
int32 calendar_year = 4;
|
||||||
|
double yearly_allowance = 5;
|
||||||
|
optional double max_days_per_month = 6;
|
||||||
|
optional double max_consecutive_days = 7;
|
||||||
|
bool apply_sandwich_policy = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HolidayData {
|
||||||
|
int32 company_id = 1;
|
||||||
|
optional int32 branch_id = 2;
|
||||||
|
int32 calendar_year = 3;
|
||||||
|
string holiday_date = 4;
|
||||||
|
string holiday_type = 5;
|
||||||
|
string holiday_name = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HolidayList {
|
||||||
|
repeated HolidayData holidays = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WorkSettingsData {
|
||||||
|
int32 company_id = 1;
|
||||||
|
optional int32 branch_id = 2;
|
||||||
|
string weekly_off_days = 3; // JSON string e.g. "[0,6]"
|
||||||
|
string effective_from = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EMPLOYEE MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message BalanceData {
|
||||||
|
int32 leave_type_id = 1;
|
||||||
|
string leave_type_name = 2;
|
||||||
|
double granted_days = 3;
|
||||||
|
double used_days = 4;
|
||||||
|
double available_balance = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BalancesResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated BalanceData balances = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HistoryData {
|
||||||
|
int32 application_id = 1;
|
||||||
|
string leave_type_name = 2;
|
||||||
|
string date_from = 3;
|
||||||
|
string date_to = 4;
|
||||||
|
double number_of_days = 5;
|
||||||
|
string status = 6;
|
||||||
|
string reason = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HistoryResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated HistoryData history = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HolidayInfo {
|
||||||
|
int32 holiday_id = 1;
|
||||||
|
string holiday_name = 2;
|
||||||
|
string holiday_date = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HolidaysResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated HolidayInfo holidays = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LEAVE APPLICATION MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message LeaveApplicationPayload {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
int32 leave_type_id = 2;
|
||||||
|
int32 company_id = 3;
|
||||||
|
int32 branch_id = 4;
|
||||||
|
string date_from = 5;
|
||||||
|
string date_to = 6;
|
||||||
|
bool is_half_day = 7;
|
||||||
|
string reason = 8;
|
||||||
|
string applicant_role = 9; // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
message ApplyLeaveResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
double total_requested = 3;
|
||||||
|
double paid_days = 4;
|
||||||
|
double lop_days = 5;
|
||||||
|
repeated int32 application_ids = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MANAGER MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message ManagerActionRequest {
|
||||||
|
int32 manager_id = 1;
|
||||||
|
int32 application_id = 2;
|
||||||
|
optional string reason = 3;
|
||||||
|
string user_role = 4; // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
message PendingLeaveData {
|
||||||
|
int32 application_id = 1;
|
||||||
|
int32 employee_id = 2;
|
||||||
|
int32 leave_type_id = 3;
|
||||||
|
string leave_type = 4;
|
||||||
|
string date_from = 5;
|
||||||
|
string date_to = 6;
|
||||||
|
double number_of_days = 7;
|
||||||
|
string reason = 8;
|
||||||
|
string status = 9;
|
||||||
|
string employee_code = 10;
|
||||||
|
string first_name = 11;
|
||||||
|
string last_name = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PendingLeavesResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated PendingLeaveData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REPORTS MESSAGES
|
||||||
|
// ==========================================
|
||||||
|
message LedgerReportRequest {
|
||||||
|
int32 month = 1;
|
||||||
|
int32 year = 2;
|
||||||
|
int32 company_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LedgerData {
|
||||||
|
int32 employee_id = 1;
|
||||||
|
string leave_type = 2;
|
||||||
|
double total_granted = 3;
|
||||||
|
double total_used = 4;
|
||||||
|
double opening_balance = 5;
|
||||||
|
double closing_balance = 6;
|
||||||
|
string employee_code = 7;
|
||||||
|
string employee_name = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LedgerReportResponse {
|
||||||
|
bool success = 1;
|
||||||
|
int32 report_month = 2;
|
||||||
|
int32 report_year = 3;
|
||||||
|
repeated LedgerData data = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HR MANAGER SERVICE (NEW)
|
||||||
|
// ==========================================
|
||||||
|
service HRManagerService {
|
||||||
|
rpc GetAllLeaveApplications(EmptyRequest) returns (AllApplicationsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message EmptyRequest {}
|
||||||
|
|
||||||
|
message AllApplicationData {
|
||||||
|
int32 application_id = 1;
|
||||||
|
int32 employee_id = 2;
|
||||||
|
string leave_type = 3;
|
||||||
|
string date_from = 4;
|
||||||
|
string date_to = 5;
|
||||||
|
double number_of_days = 6;
|
||||||
|
string status = 7;
|
||||||
|
string reason = 8;
|
||||||
|
string employee_code = 9;
|
||||||
|
string first_name = 10;
|
||||||
|
string last_name = 11;
|
||||||
|
string department_name = 12;
|
||||||
|
string job_title = 13;
|
||||||
|
string manager_name = 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AllApplicationsResponse {
|
||||||
|
bool success = 1;
|
||||||
|
repeated AllApplicationData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
24
scripts/gen_protos.sh
Executable file
24
scripts/gen_protos.sh
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
echo "🔨 Generating gRPC code using the persistent proto-compiler container..."
|
||||||
|
mkdir -p generated
|
||||||
|
podman exec hrms_proto_compiler sh -c "
|
||||||
|
protoc \
|
||||||
|
--plugin=protoc-gen-ts_proto=/usr/local/bin/protoc-gen-ts_proto \
|
||||||
|
--ts_proto_out=./generated \
|
||||||
|
--ts_proto_opt=esModuleInterop=true,useOptionals=true,outputClientImpl=grpc-js,outputServices=grpc-js,env=node \
|
||||||
|
-I ./protos \
|
||||||
|
./protos/*.proto
|
||||||
|
"
|
||||||
|
|
||||||
|
# FIX: Prepend // @ts-nocheck to all generated files to prevent Deno strict type-checking errors
|
||||||
|
for file in generated/*.ts; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
# Check if the first line is not already @ts-nocheck
|
||||||
|
if ! head -n 1 "$file" | grep -q "@ts-nocheck"; then
|
||||||
|
echo "// @ts-nocheck" | cat - "$file" > temp && mv temp "$file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "âś… gRPC code generation complete!"
|
||||||
15
services/ams/db.ts
Normal file
15
services/ams/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// services/ams/db.ts
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
||||||
|
port: Number(Deno.env.get("AMS_DB_PORT")) || 3307,
|
||||||
|
user: Deno.env.get("DB_USER") || "admin",
|
||||||
|
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||||
|
database: "hrms_ams",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
202
services/ams/handlers/attendance.handler.ts
Normal file
202
services/ams/handlers/attendance.handler.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
// services/ams/handlers/attendance.handler.ts
|
||||||
|
import * as AttendanceService from "../services/attendance.service.ts";
|
||||||
|
|
||||||
|
export const AttendanceServiceImplementation = {
|
||||||
|
async getRawLogs(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getRawLogs();
|
||||||
|
const mappedData = data.map((log: any) => ({
|
||||||
|
id: Number(log.id) || 0,
|
||||||
|
attendanceTime: log.attendance_time ? new Date(log.attendance_time).toISOString() : "",
|
||||||
|
deviceId: log.device_id || "",
|
||||||
|
employeeCode: log.employee_code || "",
|
||||||
|
employeeName: log.employee_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getRawLogs Error:", error);
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async processDailyAttendance(req: any) {
|
||||||
|
try {
|
||||||
|
// FIX: Map camelCase gRPC payload back to snake_case for the DB service layer
|
||||||
|
const dbPayload = {
|
||||||
|
work_date: req.workDate,
|
||||||
|
start_date: req.startDate,
|
||||||
|
end_date: req.endDate
|
||||||
|
};
|
||||||
|
const result = await AttendanceService.processDailyAttendance(dbPayload);
|
||||||
|
return { success: true, message: `Successfully processed ${result.processed} logs across ${result.days} days.` };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC processDailyAttendance Error:", error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEmployeeSummary(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getEmployeeSummary(Number(req.employeeId), req.startDate, req.endDate);
|
||||||
|
const metrics = data.metrics || {};
|
||||||
|
const mappedMetrics = {
|
||||||
|
totalDaysTracked: Number(metrics.total_days_tracked) || 0,
|
||||||
|
totalHoursWorked: Number(metrics.total_hours_worked) || 0,
|
||||||
|
fullDays: Number(metrics.full_days) || 0,
|
||||||
|
halfDays: Number(metrics.half_days) || 0,
|
||||||
|
absences: Number(metrics.absences) || 0,
|
||||||
|
mispunches: Number(metrics.mispunches) || 0,
|
||||||
|
lateArrivals: Number(metrics.late_arrivals) || 0
|
||||||
|
};
|
||||||
|
const mappedHistory = (data.history || []).map((h: any) => ({
|
||||||
|
attendanceId: Number(h.attendance_id) || 0,
|
||||||
|
workDate: h.work_date || "",
|
||||||
|
checkIn: h.check_in || "",
|
||||||
|
checkOut: h.check_out || "",
|
||||||
|
workedHours: Number(h.worked_hours) || 0,
|
||||||
|
checkInStatus: h.check_in_status || "",
|
||||||
|
finalStatus: h.final_status || ""
|
||||||
|
}));
|
||||||
|
return { success: true, metrics: mappedMetrics, history: mappedHistory };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getEmployeeSummary Error:", error);
|
||||||
|
// FIX: Use undefined instead of null for Protobuf nested messages
|
||||||
|
return { success: false, metrics: undefined, history: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDailyReport(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getDailyReport(req);
|
||||||
|
const summary = data.summary || {};
|
||||||
|
const mappedSummary = {
|
||||||
|
totalActiveWorkforce: Number(summary.total_active_workforce) || 0,
|
||||||
|
present: Number(summary.present) || 0,
|
||||||
|
late: Number(summary.late) || 0,
|
||||||
|
mispunches: Number(summary.mispunches) || 0,
|
||||||
|
absent: Number(summary.absent) || 0
|
||||||
|
};
|
||||||
|
const mappedRoster = (data.roster || []).map((r: any) => ({
|
||||||
|
employeeId: Number(r.employee_id) || 0,
|
||||||
|
employeeCode: r.employee_code || "",
|
||||||
|
fullName: r.full_name || "",
|
||||||
|
attendanceId: r.attendance_id ? Number(r.attendance_id) : undefined,
|
||||||
|
status: r.status || "",
|
||||||
|
checkIn: r.check_in || undefined,
|
||||||
|
checkOut: r.check_out || undefined,
|
||||||
|
workedHours: r.worked_hours ? String(r.worked_hours) : "0.00",
|
||||||
|
punctuality: r.punctuality || "N/A",
|
||||||
|
companyName: r.company_name || "",
|
||||||
|
branchName: r.branch_name || "",
|
||||||
|
departmentName: r.department_name || "",
|
||||||
|
designation: r.designation || ""
|
||||||
|
}));
|
||||||
|
return { success: true, date: data.date || "", summary: mappedSummary, roster: mappedRoster };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getDailyReport Error:", error);
|
||||||
|
return { success: false, date: "", summary: undefined, roster: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAdminRangeReport(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getAdminRangeReport(req);
|
||||||
|
const globalSummary = data.global_summary || {};
|
||||||
|
const mappedGlobalSummary = {
|
||||||
|
totalRecordsEvaluated: Number(globalSummary.total_records_evaluated) || 0,
|
||||||
|
fullDays: Number(globalSummary.full_days) || 0,
|
||||||
|
halfDays: Number(globalSummary.half_days) || 0,
|
||||||
|
lateInstances: Number(globalSummary.late_instances) || 0,
|
||||||
|
mispunches: Number(globalSummary.mispunches) || 0
|
||||||
|
};
|
||||||
|
const mappedReport = (data.report || []).map((emp: any) => ({
|
||||||
|
employeeId: Number(emp.employee_id) || 0,
|
||||||
|
employeeCode: emp.employee_code || "",
|
||||||
|
firstName: emp.first_name || "",
|
||||||
|
lastName: emp.last_name || "",
|
||||||
|
branchName: emp.branch_name || "",
|
||||||
|
departmentName: emp.department_name || "",
|
||||||
|
companyName: emp.company_name || "",
|
||||||
|
designation: emp.designation || "",
|
||||||
|
rangeMetrics: {
|
||||||
|
fullDays: Number(emp.range_metrics?.full_days) || 0,
|
||||||
|
halfDays: Number(emp.range_metrics?.half_days) || 0,
|
||||||
|
mispunches: Number(emp.range_metrics?.mispunches) || 0,
|
||||||
|
lateArrivals: Number(emp.range_metrics?.late_arrivals) || 0
|
||||||
|
},
|
||||||
|
attendanceHistory: (emp.attendance_history || []).map((h: any) => ({
|
||||||
|
attendanceId: Number(h.attendance_id) || 0,
|
||||||
|
workDate: h.work_date || "",
|
||||||
|
checkIn: h.check_in || "",
|
||||||
|
checkOut: h.check_out || "",
|
||||||
|
workedHours: Number(h.worked_hours) || 0,
|
||||||
|
checkInStatus: h.check_in_status || "",
|
||||||
|
finalStatus: h.final_status || ""
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
fromDate: data.date_range?.from || req.fromDate,
|
||||||
|
toDate: data.date_range?.to || req.toDate,
|
||||||
|
globalSummary: mappedGlobalSummary,
|
||||||
|
report: mappedReport
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getAdminRangeReport Error:", error);
|
||||||
|
return { success: false, fromDate: "", toDate: "", globalSummary: undefined, report: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSingleEmployeeRangeReport(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getSingleEmployeeRangeReport(Number(req.employeeId), req.fromDate, req.toDate);
|
||||||
|
const emp = data.employee || {};
|
||||||
|
const mappedEmployee = {
|
||||||
|
employeeId: Number(emp.employee_id) || 0,
|
||||||
|
employeeCode: emp.employee_code || "",
|
||||||
|
firstName: emp.first_name || "",
|
||||||
|
lastName: emp.last_name || ""
|
||||||
|
};
|
||||||
|
const mappedHistory = (data.history || []).map((h: any) => ({
|
||||||
|
attendanceId: Number(h.attendance_id) || 0,
|
||||||
|
workDate: h.work_date || "",
|
||||||
|
checkIn: h.check_in || "",
|
||||||
|
checkOut: h.check_out || "",
|
||||||
|
workedHours: Number(h.worked_hours) || 0,
|
||||||
|
checkInStatus: h.check_in_status || "",
|
||||||
|
finalStatus: h.final_status || ""
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
employee: mappedEmployee,
|
||||||
|
fromDate: data.range?.from || req.fromDate,
|
||||||
|
toDate: data.range?.to || req.toDate,
|
||||||
|
totalDays: Number(data.total_days) || 0,
|
||||||
|
history: mappedHistory
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getSingleEmployeeRangeReport Error:", error);
|
||||||
|
return { success: false, employee: undefined, fromDate: "", toDate: "", totalDays: 0, history: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Add these to AttendanceServiceImplementation
|
||||||
|
async exportDailyReport(req: any) {
|
||||||
|
try {
|
||||||
|
const result = await AttendanceService.exportDailyReport(req);
|
||||||
|
return { success: true, fileContent: result.file_content, fileName: result.file_name };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC exportDailyReport Error:", error);
|
||||||
|
return { success: false, fileName: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportAdminRangeReport(req: any) {
|
||||||
|
try {
|
||||||
|
const result = await AttendanceService.exportAdminRangeReport(req);
|
||||||
|
return { success: true, fileContent: result.file_content, fileName: result.file_name };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC exportAdminRangeReport Error:", error);
|
||||||
|
return { success: false, fileName: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
18
services/ams/handlers/dashboard.handler.ts
Normal file
18
services/ams/handlers/dashboard.handler.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import * as AttendanceService from "../services/attendance.service.ts";
|
||||||
|
|
||||||
|
export const DashboardServiceImplementation = {
|
||||||
|
async getDashboardMetrics(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AttendanceService.getDashboardMetrics(req);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
totalEmployees: Number(data.total_employees) || 0,
|
||||||
|
totalCheckedIn: Number(data.total_checked_in) || 0,
|
||||||
|
totalNotCheckedIn: Number(data.total_not_checked_in) || 0
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getDashboardMetrics Error:", error);
|
||||||
|
return { success: false, totalEmployees: 0, totalCheckedIn: 0, totalNotCheckedIn: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
119
services/ams/handlers/regularization.handler.ts
Normal file
119
services/ams/handlers/regularization.handler.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// services/ams/handlers/regularization.handler.ts
|
||||||
|
import * as RegService from "../services/regularization.service.ts";
|
||||||
|
|
||||||
|
// FIX: Timezone safe date formatters
|
||||||
|
const formatDateTime = (dateVal: any): string => {
|
||||||
|
if (!dateVal) return "";
|
||||||
|
if (dateVal instanceof Date) {
|
||||||
|
const offset = dateVal.getTimezoneOffset() * 60000;
|
||||||
|
const localDate = new Date(dateVal.getTime() - offset);
|
||||||
|
return localDate.toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
}
|
||||||
|
return String(dateVal).replace("T", " ").substring(0, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateVal: any): string => {
|
||||||
|
if (!dateVal) return "";
|
||||||
|
if (dateVal instanceof Date) {
|
||||||
|
const offset = dateVal.getTimezoneOffset() * 60000;
|
||||||
|
const localDate = new Date(dateVal.getTime() - offset);
|
||||||
|
return localDate.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
return String(dateVal).split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RegularizationServiceImplementation = {
|
||||||
|
async createRegularizationRequest(req: any) {
|
||||||
|
try {
|
||||||
|
const dbPayload = {
|
||||||
|
attendance_id: req.attendanceId || null,
|
||||||
|
employee_id: req.employeeId,
|
||||||
|
regularization_type: req.regularizationType,
|
||||||
|
target_date: req.targetDate,
|
||||||
|
requested_check_in: req.requestedCheckIn || null,
|
||||||
|
requested_check_out: req.requestedCheckOut || null,
|
||||||
|
reason: req.reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
await RegService.createRegularizationRequest(dbPayload);
|
||||||
|
return { success: true, message: "Regularization request submitted successfully." };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC createRegularizationRequest Error:", error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPendingRegularizations(req: any) {
|
||||||
|
try {
|
||||||
|
const user = {
|
||||||
|
role: req.userRole,
|
||||||
|
managed_branches: req.managedBranchIds || []
|
||||||
|
};
|
||||||
|
const data = await RegService.getPendingRegularizations(user);
|
||||||
|
const mappedData = data.map((reg: any) => ({
|
||||||
|
regularizationId: Number(reg.regularization_id) || 0,
|
||||||
|
employeeId: Number(reg.employee_id) || 0,
|
||||||
|
targetDate: formatDate(reg.target_date),
|
||||||
|
regularizationType: reg.regularization_type || "",
|
||||||
|
requestedCheckIn: formatDateTime(reg.requested_check_in) || undefined,
|
||||||
|
requestedCheckOut: formatDateTime(reg.requested_check_out) || undefined,
|
||||||
|
reason: reg.reason || "",
|
||||||
|
status: reg.status || "",
|
||||||
|
branchId: Number(reg.branch_id) || 0,
|
||||||
|
employeeCode: reg.employee_code || "",
|
||||||
|
firstName: reg.first_name || "",
|
||||||
|
lastName: reg.last_name || "",
|
||||||
|
branchName: reg.branch_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getPendingRegularizations Error:", error);
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reviewRegularizationRequest(req: any) {
|
||||||
|
try {
|
||||||
|
const user = {
|
||||||
|
employee_id: Number(req.reviewerId) || 0,
|
||||||
|
role: req.userRole,
|
||||||
|
managed_branches: req.managedBranchIds || []
|
||||||
|
};
|
||||||
|
const body = {
|
||||||
|
regularization_id: Number(req.regularizationId) || 0,
|
||||||
|
action: req.action
|
||||||
|
};
|
||||||
|
await RegService.reviewRegularizationRequest(user, body);
|
||||||
|
return { success: true, message: "Request has been successfully processed." };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC reviewRegularizationRequest Error:", error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllRegularizations(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await RegService.getAllRegularizations();
|
||||||
|
const mappedData = data.map((reg: any) => ({
|
||||||
|
regularizationId: Number(reg.regularization_id) || 0,
|
||||||
|
employeeId: Number(reg.employee_id) || 0,
|
||||||
|
targetDate: formatDate(reg.target_date),
|
||||||
|
regularizationType: reg.regularization_type || "",
|
||||||
|
requestedCheckIn: formatDateTime(reg.requested_check_in) || "",
|
||||||
|
requestedCheckOut: formatDateTime(reg.requested_check_out) || "",
|
||||||
|
reason: reg.reason || "",
|
||||||
|
status: reg.status || "",
|
||||||
|
branchId: Number(reg.branch_id) || 0,
|
||||||
|
employeeCode: reg.employee_code || "",
|
||||||
|
firstName: reg.first_name || "",
|
||||||
|
lastName: reg.last_name || "",
|
||||||
|
branchName: reg.branch_name || "N/A",
|
||||||
|
reviewerName: reg.reviewer_name || "N/A"
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("gRPC getAllRegularizations Error:", e);
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
33
services/ams/main.ts
Normal file
33
services/ams/main.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// services/ams/main.ts
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import { AttendanceServiceService, RegularizationServiceService, DashboardServiceService } from "../../generated/ams.ts";
|
||||||
|
import { AttendanceServiceImplementation } from "./handlers/attendance.handler.ts";
|
||||||
|
import { RegularizationServiceImplementation } from "./handlers/regularization.handler.ts";
|
||||||
|
import { DashboardServiceImplementation } from "./handlers/dashboard.handler.ts";
|
||||||
|
|
||||||
|
const PORT = Number(Deno.env.get("AMS_PORT")) || 8002;
|
||||||
|
|
||||||
|
const wrap = (impl: any) => {
|
||||||
|
const wrapped: any = {};
|
||||||
|
for (const key in impl) {
|
||||||
|
wrapped[key] = async (call: any, callback: any) => {
|
||||||
|
try {
|
||||||
|
const res = await impl[key](call.request);
|
||||||
|
callback(null, res);
|
||||||
|
} catch (err: any) {
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new grpc.Server();
|
||||||
|
server.addService(AttendanceServiceService, wrap(AttendanceServiceImplementation));
|
||||||
|
server.addService(RegularizationServiceService, wrap(RegularizationServiceImplementation));
|
||||||
|
server.addService(DashboardServiceService, wrap(DashboardServiceImplementation));
|
||||||
|
|
||||||
|
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
|
||||||
|
if (err) { console.error("Failed to start gRPC server:", err); return; }
|
||||||
|
console.log(`🚀 AMS gRPC Service running on port ${port}`);
|
||||||
|
});
|
||||||
162
services/ams/repositories/attendance.repository.ts
Normal file
162
services/ams/repositories/attendance.repository.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
// services/ams/repositories/attendance.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const findRawLogs = async () => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findDistinctRawDates = async () => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => {
|
||||||
|
if (r.raw_date instanceof Date) {
|
||||||
|
const offset = r.raw_date.getTimezoneOffset();
|
||||||
|
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
|
||||||
|
return localDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
return String(r.raw_date);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findRawLogsByDate = async (targetDate: string) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
|
||||||
|
FROM attendance_raw_logs
|
||||||
|
WHERE DATE(attendance_time) = ?
|
||||||
|
ORDER BY employee_code, attendance_time ASC`,
|
||||||
|
[targetDate]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertProcessedAttendance = async (data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO processed_daily_attendance
|
||||||
|
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
check_in = VALUES(check_in),
|
||||||
|
check_out = VALUES(check_out),
|
||||||
|
worked_hours = VALUES(worked_hours),
|
||||||
|
check_in_status = VALUES(check_in_status),
|
||||||
|
final_status = VALUES(final_status)`,
|
||||||
|
[data.employeeId, data.company_id, data.branch_id, data.targetDate, data.check_in, data.check_out, data.worked_hours, data.check_in_status, data.final_status]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date DESC`,
|
||||||
|
[employeeId, startDate, endDate]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findDailyAttendance = async (targetDate: string) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT employee_id, attendance_id,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date = ?`,
|
||||||
|
[targetDate]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAttendanceByEmployeeIds = async (employeeIds: number[], fromDate: string, toDate: string) => {
|
||||||
|
if (employeeIds.length === 0) return [];
|
||||||
|
const placeholders = employeeIds.map(() => "?").join(",");
|
||||||
|
|
||||||
|
// console.log("DB Query Params:", { fromDate, toDate, employeeIds });
|
||||||
|
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT
|
||||||
|
employee_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[fromDate, toDate, ...employeeIds]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findSingleEmployeeAttendance = async (employeeId: number, fromDate: string, toDate: string) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[employeeId, fromDate, toDate]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findShiftDetails = async (branchId: number, companyId: number, conn?: any) => {
|
||||||
|
const query = `SELECT start_time, grace_period_minutes FROM shifts
|
||||||
|
WHERE (branch_id = ? OR branch_id IS NULL) AND company_id = ?
|
||||||
|
ORDER BY branch_id IS NULL ASC LIMIT 1`;
|
||||||
|
|
||||||
|
let rows;
|
||||||
|
if (conn) {
|
||||||
|
[rows] = await conn.execute(query, [branchId, companyId]);
|
||||||
|
} else {
|
||||||
|
[rows] = await pool.execute(query, [branchId, companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProcessedAttendanceById = async (attendanceId: number, data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE processed_daily_attendance
|
||||||
|
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
|
||||||
|
WHERE attendance_id = ?`,
|
||||||
|
[data.requested_check_in, data.requested_check_out, data.worked_hours, data.final_status, data.check_in_status, attendanceId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the applicable policy for the company/branch
|
||||||
|
export const findAttendancePolicy = async (companyId: number, branchId: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT * FROM attendance_policies
|
||||||
|
WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) AND is_active = TRUE
|
||||||
|
ORDER BY branch_id DESC LIMIT 1`,
|
||||||
|
[companyId, branchId]
|
||||||
|
);
|
||||||
|
// Fallback to hardcoded defaults if HR hasn't configured it yet
|
||||||
|
return rows[0] || { min_hours_full_day: 7.0, min_hours_half_day: 4.0, late_grace_period_minutes: 10, max_lates_allowed_per_month: 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count how many lates the employee has already had this month
|
||||||
|
export const countLatesThisMonth = async (employeeId: number, targetDate: string, conn: any) => {
|
||||||
|
const dateObj = new Date(targetDate);
|
||||||
|
const year = dateObj.getFullYear();
|
||||||
|
const month = dateObj.getMonth() + 1; // 1-12
|
||||||
|
const firstDayOfMonth = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
const [rows]: any = await conn.execute(
|
||||||
|
`SELECT COUNT(*) as late_count
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND check_in_status = 'LATE'
|
||||||
|
AND work_date >= ? AND work_date < ?`,
|
||||||
|
[employeeId, firstDayOfMonth, targetDate]
|
||||||
|
);
|
||||||
|
return rows[0]?.late_count || 0;
|
||||||
|
};
|
||||||
55
services/ams/repositories/regularization.repository.ts
Normal file
55
services/ams/repositories/regularization.repository.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// services/ams/repositories/regularization.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const insertRegularization = async (data: any) => {
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO attendance_regularizations
|
||||||
|
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
|
||||||
|
[data.attendance_id || null, data.employee_id, data.branch_id || null, data.regularization_type, data.target_date, data.requested_check_in || null, data.requested_check_out || null, data.reason]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPendingRegularizations = async (branchIds: number[] | null) => {
|
||||||
|
let query = `SELECT regularization_id, employee_id, target_date, regularization_type, requested_check_in, requested_check_out, reason, status, branch_id FROM attendance_regularizations WHERE status = 'PENDING'`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (branchIds && branchIds.length > 0) {
|
||||||
|
const placeholders = branchIds.map(() => "?").join(",");
|
||||||
|
query += ` AND branch_id IN (${placeholders})`;
|
||||||
|
params.push(...branchIds);
|
||||||
|
}
|
||||||
|
query += ` ORDER BY target_date DESC`;
|
||||||
|
|
||||||
|
// FIX: Cast result as any[]
|
||||||
|
const [rows]: any[] = await pool.query(query, params);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findRegularizationById = async (id: number, conn: any) => {
|
||||||
|
// FIX: Cast result as any[]
|
||||||
|
const [rows]: any[] = await conn.execute(
|
||||||
|
`SELECT regularization_id, attendance_id, branch_id, employee_id, requested_check_in, requested_check_out, status
|
||||||
|
FROM attendance_regularizations WHERE regularization_id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRegularizationStatus = async (id: number, status: string, reviewerId: number, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE attendance_regularizations SET status = ?, reviewed_by_id = ? WHERE regularization_id = ?`,
|
||||||
|
[status, reviewerId, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAllRegularizations = async () => {
|
||||||
|
// FIX: Cast result as any[]
|
||||||
|
const [rows]: any[] = await pool.execute(
|
||||||
|
`SELECT regularization_id, employee_id, target_date, regularization_type,
|
||||||
|
requested_check_in, requested_check_out, reason, status, branch_id, reviewed_by_id
|
||||||
|
FROM attendance_regularizations
|
||||||
|
ORDER BY target_date DESC`
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
820
services/ams/services/attendance.service.ts
Normal file
820
services/ams/services/attendance.service.ts
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
// services/ams/services/attendance.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import { getEmployeeRoster } from "core/internal-client.ts";
|
||||||
|
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
|
||||||
|
import ExcelJS from "npm:exceljs@4.4.0";
|
||||||
|
import PDFDocument from "npm:pdfkit@0.13.0";
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to draw tables in PDF
|
||||||
|
function drawPdfTable(doc: any, headers: string[], rows: any[], columnWidths: number[]) {
|
||||||
|
const startX = doc.page.margins.left;
|
||||||
|
let y = doc.y;
|
||||||
|
const cellPadding = 5;
|
||||||
|
|
||||||
|
// 1. Draw Headers
|
||||||
|
doc.font('Helvetica-Bold').fontSize(9);
|
||||||
|
let headerHeight = 20;
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
const textHeight = doc.heightOfString(header, { width: columnWidths[i] - cellPadding * 2 });
|
||||||
|
headerHeight = Math.max(headerHeight, textHeight + cellPadding * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
let x = startX;
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
// Dark Green background, White text, Black border (Matching Excel)
|
||||||
|
doc.fill('#063B00').rect(x, y, columnWidths[i], headerHeight).fill();
|
||||||
|
doc.fillColor('#FFFFFF').text(header, x + cellPadding, y + cellPadding, { width: columnWidths[i] - cellPadding * 2, align: 'center' });
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, columnWidths[i], headerHeight).stroke();
|
||||||
|
x += columnWidths[i];
|
||||||
|
});
|
||||||
|
y += headerHeight;
|
||||||
|
|
||||||
|
// 2. Draw Rows
|
||||||
|
doc.font('Helvetica').fontSize(8);
|
||||||
|
rows.forEach((row: any) => {
|
||||||
|
let rowHeight = 15;
|
||||||
|
|
||||||
|
row.forEach((cell: any, i: number) => {
|
||||||
|
const textHeight = doc.heightOfString(String(cell ?? '-'), { width: columnWidths[i] - cellPadding * 2 });
|
||||||
|
rowHeight = Math.max(rowHeight, textHeight + cellPadding * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (y + rowHeight > doc.page.height - doc.page.margins.bottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = doc.page.margins.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
x = startX;
|
||||||
|
row.forEach((cell: any, i: number) => {
|
||||||
|
let textColor = '#000000'; // Default black
|
||||||
|
let bgColor = null;
|
||||||
|
|
||||||
|
// Apply background and text colors for the Status column (always the last column)
|
||||||
|
if (i === headers.length - 1) {
|
||||||
|
const statusStr = String(cell ?? '').toUpperCase();
|
||||||
|
if (statusStr === 'FULL_DAY') {
|
||||||
|
bgColor = '#C6EFCE'; textColor = '#006100';
|
||||||
|
} else if (statusStr === 'ABSENT') {
|
||||||
|
bgColor = '#FFC7CE'; textColor = '#9C0006';
|
||||||
|
} else if (statusStr === 'HALF_DAY') {
|
||||||
|
bgColor = '#FFEB9C'; textColor = '#9C6500';
|
||||||
|
} else if (statusStr === 'MISPUNCH') {
|
||||||
|
bgColor = '#B1A0C7'; textColor = '#4B2E83';
|
||||||
|
} else if (statusStr === 'LATE') {
|
||||||
|
bgColor = '#FFC000'; textColor = '#000000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw background color if applicable
|
||||||
|
if (bgColor) {
|
||||||
|
doc.fillColor(bgColor).rect(x, y, columnWidths[i], rowHeight).fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text
|
||||||
|
doc.fillColor(textColor).text(String(cell ?? '-'), x + cellPadding, y + cellPadding, { width: columnWidths[i] - cellPadding * 2 });
|
||||||
|
|
||||||
|
// Draw black border for rows
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, columnWidths[i], rowHeight).stroke();
|
||||||
|
x += columnWidths[i];
|
||||||
|
});
|
||||||
|
y += rowHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.y = y + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function specifically for Excel Daily Report generation
|
||||||
|
async function generateExcelDailyReport(roster: any[], formattedDate: string, dayName: string, generatedDate: string, summary: any): Promise<Uint8Array> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Daily Report');
|
||||||
|
|
||||||
|
// Merge cells across 12 columns (A to L)
|
||||||
|
worksheet.mergeCells('A1:L1');
|
||||||
|
const titleCell = worksheet.getCell('A1');
|
||||||
|
titleCell.value = 'Report Name : Daily Attendance Report';
|
||||||
|
titleCell.font = { size: 11, bold: true };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
worksheet.mergeCells('A2:L2');
|
||||||
|
const dateCell = worksheet.getCell('A2');
|
||||||
|
dateCell.value = `Date : ${formattedDate} (${dayName})`;
|
||||||
|
dateCell.font = { size: 11, bold: true };
|
||||||
|
dateCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
worksheet.mergeCells('A3:L3');
|
||||||
|
const genDateCell = worksheet.getCell('A3');
|
||||||
|
genDateCell.value = `Generated Date : ${generatedDate}`;
|
||||||
|
genDateCell.font = { size: 11, bold: true };
|
||||||
|
genDateCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
// NEW: Summary Metrics Row
|
||||||
|
worksheet.mergeCells('A4:L4');
|
||||||
|
const summaryCell = worksheet.getCell('A4');
|
||||||
|
summaryCell.value = `Total employees: ${summary.total_active_workforce || 0} | Present: ${summary.present || 0} | Late: ${summary.late || 0} | Mispunches: ${summary.mispunches || 0} | Absent: ${summary.absent || 0}`;
|
||||||
|
summaryCell.font = { size: 11, bold: true, color: { argb: 'FF000000' } }; // Black text
|
||||||
|
summaryCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
worksheet.getRow(5).height = 10; // Spacer row
|
||||||
|
|
||||||
|
// Row 6: Headers (12 columns)
|
||||||
|
const headerRow = worksheet.getRow(6);
|
||||||
|
headerRow.values = ['Date', 'Day', 'Emp Code', 'Name', 'Company', 'Branch', 'Department', 'Job Role', 'Check-In', 'Check-Out', 'Worked Hours', 'Status'];
|
||||||
|
|
||||||
|
// Style Header Row (Green Background, White Text)
|
||||||
|
headerRow.eachCell((cell) => {
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: '063B00' } // Dark Green
|
||||||
|
};
|
||||||
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; // White text
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin' },
|
||||||
|
left: { style: 'thin' },
|
||||||
|
bottom: { style: 'thin' },
|
||||||
|
right: { style: 'thin' }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
headerRow.height = 20;
|
||||||
|
|
||||||
|
// Add Data Rows starting from Row 7
|
||||||
|
roster.forEach((r: any, index: number) => {
|
||||||
|
const rowIndex = 7 + index;
|
||||||
|
const row = worksheet.getRow(rowIndex);
|
||||||
|
row.values = [
|
||||||
|
formattedDate,
|
||||||
|
dayName,
|
||||||
|
r.employee_code,
|
||||||
|
r.full_name,
|
||||||
|
r.company_name || '-',
|
||||||
|
r.branch_name || '-',
|
||||||
|
r.department_name || '-',
|
||||||
|
r.designation || '-',
|
||||||
|
r.check_in ? r.check_in.substring(0, 5) : '-',
|
||||||
|
r.check_out ? r.check_out.substring(0, 5) : '-',
|
||||||
|
r.worked_hours,
|
||||||
|
r.status
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add borders to data cells
|
||||||
|
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin' },
|
||||||
|
left: { style: 'thin' },
|
||||||
|
bottom: { style: 'thin' },
|
||||||
|
right: { style: 'thin' }
|
||||||
|
};
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
// Apply colors to the Status column (Column 12 / 'L')
|
||||||
|
if (colNumber === 12) {
|
||||||
|
const statusStr = (r.status || '').toUpperCase();
|
||||||
|
if (statusStr === 'FULL_DAY') {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }; // Light Green background
|
||||||
|
cell.font = { color: { argb: 'FF006100' }, bold: true }; // Dark Green text
|
||||||
|
} else if (statusStr === 'ABSENT') {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } }; // Light Red background
|
||||||
|
cell.font = { color: { argb: 'FF9C0006' }, bold: true }; // Dark Red text
|
||||||
|
} else if (statusStr === 'HALF_DAY') {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFEB9C' } }; // Light Orange/Yellow background
|
||||||
|
cell.font = { color: { argb: 'FF9C6500' }, bold: true }; // Dark Orange text
|
||||||
|
} else if (statusStr === 'MISPUNCH') {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFB1A0C7' } }; // Light Purple background
|
||||||
|
cell.font = { color: { argb: 'FF4B2E83' }, bold: true }; // Dark Purple text
|
||||||
|
} else if (statusStr === 'LATE') {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC000' } }; // Gold background
|
||||||
|
cell.font = { color: { argb: 'FF000000' }, bold: true }; // Black text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.commit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set 12 Column Widths (Increased Day width to 70 to prevent "Wednesday" text wrapping)
|
||||||
|
worksheet.columns = [
|
||||||
|
{ width: 12 }, // Date
|
||||||
|
{ width: 65 }, // Day
|
||||||
|
{ width: 12 }, // Emp Code
|
||||||
|
{ width: 25 }, // Name
|
||||||
|
{ width: 22 }, // Company
|
||||||
|
{ width: 18 }, // Branch
|
||||||
|
{ width: 22 }, // Department
|
||||||
|
{ width: 25 }, // Job Role
|
||||||
|
{ width: 12 }, // Check-In
|
||||||
|
{ width: 12 }, // Check-Out
|
||||||
|
{ width: 12 }, // Worked Hours
|
||||||
|
{ width: 12 }, // Status
|
||||||
|
];
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function specifically for Excel Monthly Report generation (Matrix Layout)
|
||||||
|
async function generateExcelMonthlyReport(report: any[], dates: string[], formattedFromDate: string, formattedToDate: string, generatedDate: string): Promise<Uint8Array> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet('Monthly Attendance');
|
||||||
|
|
||||||
|
const numDates = dates.length;
|
||||||
|
const totalCols = 1 + numDates;
|
||||||
|
|
||||||
|
// Top Level Headings
|
||||||
|
worksheet.mergeCells(1, 1, 1, totalCols);
|
||||||
|
const titleCell = worksheet.getCell(1, 1);
|
||||||
|
titleCell.value = 'Report Name : Monthly Attendance Report';
|
||||||
|
titleCell.font = { size: 11, bold: true };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
worksheet.mergeCells(2, 1, 2, totalCols);
|
||||||
|
const periodCell = worksheet.getCell(2, 1);
|
||||||
|
periodCell.value = `Period : ${formattedFromDate} To ${formattedToDate}`;
|
||||||
|
periodCell.font = { size: 11, bold: true };
|
||||||
|
periodCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
worksheet.mergeCells(3, 1, 3, totalCols);
|
||||||
|
const genDateCell = worksheet.getCell(3, 1);
|
||||||
|
genDateCell.value = `Generated Date : ${generatedDate}`;
|
||||||
|
genDateCell.font = { size: 11, bold: true };
|
||||||
|
genDateCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
// Column Widths
|
||||||
|
worksheet.getColumn(1).width = 12;
|
||||||
|
for (let i = 0; i < numDates; i++) {
|
||||||
|
worksheet.getColumn(i + 2).width = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentRow = 5;
|
||||||
|
|
||||||
|
report.forEach((emp: any) => {
|
||||||
|
const history = emp.attendance_history || [];
|
||||||
|
|
||||||
|
// --- Employee Summary Header ---
|
||||||
|
worksheet.mergeCells(currentRow, 1, currentRow, totalCols);
|
||||||
|
const empCell = worksheet.getCell(currentRow, 1);
|
||||||
|
empCell.value = `Employee Code: ${emp.employee_code} | Name: ${emp.first_name} ${emp.last_name} | Company: ${emp.company_name || '-'} | Branch: ${emp.branch_name || '-'} | Department: ${emp.department_name || '-'} | Job Role: ${emp.designation || '-'} | Full Days: ${emp.range_metrics?.full_days || 0} | Half Days: ${emp.range_metrics?.half_days || 0} | Mispunches: ${emp.range_metrics?.mispunches || 0} | Late Arrivals: ${emp.range_metrics?.late_arrivals || 0}`;
|
||||||
|
empCell.font = { bold: true, size: 10 };
|
||||||
|
empCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2F2F2' } };
|
||||||
|
empCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
currentRow++;
|
||||||
|
|
||||||
|
// --- Table Headers (Field | Date 1 | Date 2 | ...) ---
|
||||||
|
const headerRow = worksheet.getRow(currentRow);
|
||||||
|
headerRow.getCell(1).value = 'Field';
|
||||||
|
for (let i = 0; i < numDates; i++) {
|
||||||
|
const dateObj = new Date(dates[i] + 'T00:00:00');
|
||||||
|
const dayStr = dateObj.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
||||||
|
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
headerRow.getCell(i + 2).value = `${dayStr}\n${dayName}`;
|
||||||
|
}
|
||||||
|
headerRow.eachCell((cell) => {
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '063B00' } }; // Dark Green
|
||||||
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; // White text
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
cell.border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
|
||||||
|
});
|
||||||
|
headerRow.height = 25;
|
||||||
|
currentRow++;
|
||||||
|
|
||||||
|
// --- Data Rows (Check-In, Check-Out, Hours, Status) ---
|
||||||
|
const fields = ['Check-In', 'Check-Out', 'Hours', 'Status'];
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const row = worksheet.getRow(currentRow);
|
||||||
|
row.getCell(1).value = field;
|
||||||
|
row.getCell(1).font = { bold: true };
|
||||||
|
row.getCell(1).alignment = { horizontal: 'left' };
|
||||||
|
row.getCell(1).border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
|
||||||
|
|
||||||
|
for (let i = 0; i < numDates; i++) {
|
||||||
|
const log = history.find((h: any) => h.work_date === dates[i]);
|
||||||
|
const cell = row.getCell(i + 2);
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
if (field === 'Check-In') cell.value = log.check_in ? log.check_in.substring(0, 5) : '-';
|
||||||
|
else if (field === 'Check-Out') cell.value = log.check_out ? log.check_out.substring(0, 5) : '-';
|
||||||
|
else if (field === 'Hours') cell.value = log.worked_hours;
|
||||||
|
else if (field === 'Status') {
|
||||||
|
cell.value = log.final_status;
|
||||||
|
const statusStr = (log.final_status || '').toUpperCase();
|
||||||
|
if (statusStr === 'FULL_DAY') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFC6EFCE'} }; cell.font = { color:{argb:'FF006100'}, bold:true }; }
|
||||||
|
else if (statusStr === 'ABSENT') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFC7CE'} }; cell.font = { color:{argb:'FF9C0006'}, bold:true }; }
|
||||||
|
else if (statusStr === 'HALF_DAY') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFEB9C'} }; cell.font = { color:{argb:'FF9C6500'}, bold:true }; }
|
||||||
|
else if (statusStr === 'MISPUNCH') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFB1A0C7'} }; cell.font = { color:{argb:'FF4B2E83'}, bold:true }; }
|
||||||
|
else if (statusStr === 'LATE') { cell.fill = { type:'pattern', pattern:'solid', fgColor:{argb:'FFFFC000'} }; cell.font = { color:{argb:'FF000000'}, bold:true }; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.value = '-';
|
||||||
|
}
|
||||||
|
cell.border = { top: {style:'thin', color:{argb:'FF000000'}}, left: {style:'thin', color:{argb:'FF000000'}}, bottom: {style:'thin', color:{argb:'FF000000'}}, right: {style:'thin', color:{argb:'FF000000'}} };
|
||||||
|
cell.alignment = { horizontal: 'center' };
|
||||||
|
}
|
||||||
|
currentRow++;
|
||||||
|
});
|
||||||
|
currentRow++; // empty row between employees
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to apply filters to the roster
|
||||||
|
const applyRosterFilters = (roster: any[], filters: any) => {
|
||||||
|
let filtered = [...roster];
|
||||||
|
if (filters.companyId) filtered = filtered.filter(e => e.companyId === Number(filters.companyId));
|
||||||
|
if (filters.branchId) filtered = filtered.filter(e => e.branchId === Number(filters.branchId));
|
||||||
|
if (filters.departmentId) filtered = filtered.filter(e => e.departmentId === Number(filters.departmentId));
|
||||||
|
if (filters.designation) {
|
||||||
|
const desigStr = String(filters.designation).toLowerCase();
|
||||||
|
const desigNum = Number(filters.designation);
|
||||||
|
filtered = filtered.filter(e =>
|
||||||
|
e.jobTitle?.toLowerCase().includes(desigStr) ||
|
||||||
|
e.jobTitleId === desigNum ||
|
||||||
|
e.jobRoleId === desigNum
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.employeeName) filtered = filtered.filter(e => e.fullName?.toLowerCase().includes(filters.employeeName.toLowerCase()) ?? false);
|
||||||
|
if (filters.employeeCode) filtered = filtered.filter(e => e.employeeCode?.toLowerCase().includes(filters.employeeCode.toLowerCase()) ?? false);
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysArray = (start: string, end: string): string[] => {
|
||||||
|
const arr: string[] = [];
|
||||||
|
const dt = new Date(start);
|
||||||
|
const endDt = new Date(end);
|
||||||
|
while (dt <= endDt) {
|
||||||
|
arr.push(new Date(dt).toISOString().split('T')[0]);
|
||||||
|
dt.setDate(dt.getDate() + 1);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRawLogs = async () => {
|
||||||
|
return await AttendanceRepo.findRawLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processDailyAttendance = async (body: any) => {
|
||||||
|
|
||||||
|
console.log("AMS Worker Received Body:", JSON.stringify(body));
|
||||||
|
let datesToProcess: string[] = [];
|
||||||
|
|
||||||
|
if (body.work_date && body.work_date.length > 0) {
|
||||||
|
datesToProcess.push(body.work_date);
|
||||||
|
} else if (body.start_date?.length > 0 && body.end_date?.length > 0) {
|
||||||
|
datesToProcess = getDaysArray(body.start_date, body.end_date);
|
||||||
|
} else {
|
||||||
|
datesToProcess = await AttendanceRepo.findDistinctRawDates();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datesToProcess.length === 0) throw new Error("No dates found to process.");
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let totalProcessedRecords = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const employeeRoster = await getEmployeeRoster();
|
||||||
|
|
||||||
|
console.log(`[AMS Worker] Fetched ${employeeRoster.length} employees from EMS.`);
|
||||||
|
if (employeeRoster.length > 0) {
|
||||||
|
console.log("[AMS Worker] First employee in roster:", employeeRoster[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employeeCode, e]));
|
||||||
|
|
||||||
|
for (const targetDate of datesToProcess) {
|
||||||
|
const rawLogs = await AttendanceRepo.findRawLogsByDate(targetDate);
|
||||||
|
if (rawLogs.length === 0) continue;
|
||||||
|
|
||||||
|
console.log(`[AMS Worker] Date ${targetDate}: Found ${rawLogs.length} raw logs.`);
|
||||||
|
|
||||||
|
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
|
||||||
|
if (!acc[log.employee_code]) acc[log.employee_code] = [];
|
||||||
|
acc[log.employee_code].push(log.att_time);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
for (const empCode in groupedLogs) {
|
||||||
|
const employee = employeeMap.get(empCode);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!employee.isActive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const punches = groupedLogs[empCode];
|
||||||
|
const shift = await AttendanceRepo.findShiftDetails(employee.branchId, employee.companyId, connection);
|
||||||
|
|
||||||
|
const policy = await AttendanceRepo.findAttendancePolicy(employee.companyId, employee.branchId);
|
||||||
|
const effectiveGracePeriod = policy.late_grace_period_minutes ?? shift.grace_period_minutes;
|
||||||
|
|
||||||
|
let check_in = null, check_out = null, worked_hours = 0.0;
|
||||||
|
let check_in_status = "ON_TIME", final_status = "ABSENT";
|
||||||
|
|
||||||
|
if (punches.length >= 2) {
|
||||||
|
check_in = punches[0];
|
||||||
|
check_out = punches[punches.length - 1];
|
||||||
|
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
|
||||||
|
let lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
|
||||||
|
|
||||||
|
if (shift.is_night_shift && lastPunchMs < firstPunchMs) {
|
||||||
|
lastPunchMs += 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
|
||||||
|
|
||||||
|
if (worked_hours >= Number(policy.min_hours_full_day)) final_status = "FULL_DAY";
|
||||||
|
else if (worked_hours >= Number(policy.min_hours_half_day)) final_status = "HALF_DAY";
|
||||||
|
else final_status = "ABSENT";
|
||||||
|
|
||||||
|
const rawCheckInTimeStr = check_in.split(' ')[1];
|
||||||
|
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
||||||
|
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
||||||
|
|
||||||
|
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
||||||
|
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (effectiveGracePeriod * 60);
|
||||||
|
|
||||||
|
if (punchTotalSeconds > shiftCutoffSeconds) {
|
||||||
|
check_in_status = "LATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check_in_status === "LATE" && final_status === "FULL_DAY") {
|
||||||
|
const latesThisMonth = await AttendanceRepo.countLatesThisMonth(employee.employeeId, targetDate, connection);
|
||||||
|
if (latesThisMonth >= policy.max_lates_allowed_per_month) {
|
||||||
|
final_status = "HALF_DAY";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (punches.length === 1) {
|
||||||
|
check_in = punches[0];
|
||||||
|
check_in_status = "MISPUNCH";
|
||||||
|
final_status = "MISPUNCH";
|
||||||
|
}
|
||||||
|
|
||||||
|
await AttendanceRepo.upsertProcessedAttendance({
|
||||||
|
employeeId: employee.employeeId,
|
||||||
|
company_id: employee.companyId,
|
||||||
|
branch_id: employee.branchId,
|
||||||
|
targetDate, check_in, check_out, worked_hours, check_in_status, final_status
|
||||||
|
}, connection);
|
||||||
|
|
||||||
|
totalProcessedRecords++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return { processed: totalProcessedRecords, days: datesToProcess.length };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
|
||||||
|
const rows = await AttendanceRepo.findEmployeeSummary(employeeId, startDate, endDate);
|
||||||
|
|
||||||
|
const summaryMetrics = rows.reduce((acc: any, row: any) => {
|
||||||
|
acc.total_days_tracked++;
|
||||||
|
acc.total_hours_worked += Number(row.worked_hours || 0);
|
||||||
|
if (row.final_status === "FULL_DAY") acc.full_days++;
|
||||||
|
else if (row.final_status === "HALF_DAY") acc.half_days++;
|
||||||
|
else if (row.final_status === "ABSENT") acc.absences++;
|
||||||
|
else if (row.final_status === "MISPUNCH") acc.mispunches++;
|
||||||
|
if (row.check_in_status === "LATE") acc.late_arrivals++;
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
total_days_tracked: 0, total_hours_worked: 0, full_days: 0, half_days: 0, absences: 0, mispunches: 0, late_arrivals: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
|
||||||
|
return { metrics: summaryMetrics, history: rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyReport = async (req: any) => {
|
||||||
|
const attendanceRows = await AttendanceRepo.findDailyAttendance(req.date);
|
||||||
|
let employeeRoster = await getEmployeeRoster();
|
||||||
|
|
||||||
|
employeeRoster = applyRosterFilters(employeeRoster, req);
|
||||||
|
|
||||||
|
const summary = { total_active_workforce: employeeRoster.length, present: 0, late: 0, mispunches: 0, absent: 0 };
|
||||||
|
|
||||||
|
const roster = employeeRoster.map((emp: any) => {
|
||||||
|
const attendance = attendanceRows.find((a: any) => a.employee_id === emp.employeeId);
|
||||||
|
let status = "ABSENT", check_in = null, check_out = null, worked_hours = "0.00", punctuality = "N/A", attendance_id = null;
|
||||||
|
|
||||||
|
if (attendance) {
|
||||||
|
status = attendance.final_status;
|
||||||
|
check_in = attendance.check_in;
|
||||||
|
check_out = attendance.check_out;
|
||||||
|
worked_hours = attendance.worked_hours;
|
||||||
|
punctuality = attendance.check_in_status;
|
||||||
|
attendance_id = attendance.attendance_id;
|
||||||
|
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
|
||||||
|
if (status === "MISPUNCH") summary.mispunches++;
|
||||||
|
if (punctuality === "LATE") summary.late++;
|
||||||
|
} else {
|
||||||
|
summary.absent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: emp.employeeId,
|
||||||
|
employee_code: emp.employeeCode,
|
||||||
|
full_name: emp.fullName,
|
||||||
|
attendance_id, status, check_in, check_out, worked_hours, punctuality,
|
||||||
|
company_name: emp.companyName,
|
||||||
|
branch_name: emp.branchName,
|
||||||
|
department_name: emp.departmentName,
|
||||||
|
designation: emp.jobTitle
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { date: req.date, summary, roster };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminRangeReport = async (req: any) => {
|
||||||
|
let employeeRoster = await getEmployeeRoster();
|
||||||
|
|
||||||
|
employeeRoster = applyRosterFilters(employeeRoster, req);
|
||||||
|
|
||||||
|
if (employeeRoster.length === 0) return { date_range: { from: req.fromDate, to: req.toDate }, global_summary: { total_records_evaluated: 0, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 }, report: [] };
|
||||||
|
|
||||||
|
const employeeIds = employeeRoster.map((e: any) => e.employeeId);
|
||||||
|
const attendanceRows = await AttendanceRepo.findAttendanceByEmployeeIds(employeeIds, req.fromDate, req.toDate);
|
||||||
|
|
||||||
|
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
|
||||||
|
if (!acc[row.employee_id]) acc[row.employee_id] = [];
|
||||||
|
acc[row.employee_id].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
|
||||||
|
|
||||||
|
const report = employeeRoster.map((emp: any) => {
|
||||||
|
const logs = attendanceGrouped[emp.employeeId] || [];
|
||||||
|
const individualMetrics = logs.reduce((acc: any, log: any) => {
|
||||||
|
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
|
||||||
|
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
|
||||||
|
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
|
||||||
|
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
|
||||||
|
return acc;
|
||||||
|
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: emp.employeeId,
|
||||||
|
employee_code: emp.employeeCode,
|
||||||
|
first_name: emp.fullName?.split(" ")[0] || "",
|
||||||
|
last_name: emp.fullName?.split(" ").slice(1).join(" ") || "",
|
||||||
|
branch_name: emp.branchName,
|
||||||
|
department_name: emp.departmentName,
|
||||||
|
company_name: emp.companyName,
|
||||||
|
designation: emp.jobTitle,
|
||||||
|
range_metrics: individualMetrics,
|
||||||
|
attendance_history: logs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { date_range: { from: req.fromDate, to: req.toDate }, global_summary: globalSummary, report };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSingleEmployeeRangeReport = async (employeeId: number, fromDate: string, toDate: string) => {
|
||||||
|
const roster = await getEmployeeRoster([employeeId]);
|
||||||
|
const empProfile = roster[0];
|
||||||
|
if (!empProfile) throw new Error("Employee profile not found.");
|
||||||
|
|
||||||
|
const logs = await AttendanceRepo.findSingleEmployeeAttendance(employeeId, fromDate, toDate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee: {
|
||||||
|
employee_id: empProfile.employeeId,
|
||||||
|
employee_code: empProfile.employeeCode,
|
||||||
|
first_name: empProfile.fullName?.split(" ")[0] || "",
|
||||||
|
last_name: empProfile.fullName?.split(" ").slice(1).join(" ") || ""
|
||||||
|
},
|
||||||
|
range: { from: fromDate, to: toDate },
|
||||||
|
total_days: logs.length,
|
||||||
|
history: logs
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDashboardMetrics = async (req: any) => {
|
||||||
|
let employeeRoster = await getEmployeeRoster();
|
||||||
|
|
||||||
|
employeeRoster = applyRosterFilters(employeeRoster, req);
|
||||||
|
|
||||||
|
const attendanceRows = await AttendanceRepo.findDailyAttendance(req.date);
|
||||||
|
|
||||||
|
const checkedInIds = new Set(attendanceRows.map((r: any) => r.employee_id));
|
||||||
|
|
||||||
|
const totalEmployees = employeeRoster.length;
|
||||||
|
const totalCheckedIn = employeeRoster.filter((emp: any) => checkedInIds.has(emp.employeeId)).length;
|
||||||
|
const totalNotCheckedIn = totalEmployees - totalCheckedIn;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_employees: totalEmployees,
|
||||||
|
total_checked_in: totalCheckedIn,
|
||||||
|
total_not_checked_in: totalNotCheckedIn
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportDailyReport = async (req: any) => {
|
||||||
|
const data = await getDailyReport(req);
|
||||||
|
const roster = data.roster || [];
|
||||||
|
const summary = data.summary || {};
|
||||||
|
const fileType = req.fileType || 'excel';
|
||||||
|
|
||||||
|
let file_content: Uint8Array;
|
||||||
|
let file_name: string;
|
||||||
|
|
||||||
|
const reportDate = new Date(req.date + 'T00:00:00');
|
||||||
|
const formattedDate = reportDate.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
const dayName = reportDate.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const generatedDate = new Date().toLocaleString('en-GB', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileType === 'pdf') {
|
||||||
|
const doc = new PDFDocument({ margin: 30, size: 'A4', layout: 'landscape' });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
doc.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
||||||
|
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Report Name : Daily Attendance Report`, { align: 'left' });
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Date : ${formattedDate} (${dayName})`, { align: 'left' });
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Generated Date : ${generatedDate}`, { align: 'left' });
|
||||||
|
|
||||||
|
// Add Summary Metrics Line (Black color)
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Total employees: ${summary.total_active_workforce || 0} | Present: ${summary.present || 0} | Late: ${summary.late || 0} | Mispunches: ${summary.mispunches || 0} | Absent: ${summary.absent || 0}`, { align: 'left' });
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
const headers = ['Date', 'Day', 'Emp Code', 'Name', 'Company', 'Branch', 'Department', 'Job Role', 'Check-In', 'Check-Out', 'Hrs', 'Status'];
|
||||||
|
const rows = roster.map((r: any) => [
|
||||||
|
formattedDate, dayName, r.employee_code, r.full_name, r.company_name || '-', r.branch_name || '-',
|
||||||
|
r.department_name || '-', r.designation || '-',
|
||||||
|
r.check_in ? r.check_in.substring(0, 5) : '-',
|
||||||
|
r.check_out ? r.check_out.substring(0, 5) : '-',
|
||||||
|
r.worked_hours, r.status
|
||||||
|
]);
|
||||||
|
|
||||||
|
// INCREASED Day column width to 70 to prevent "Wednesday" from wrapping
|
||||||
|
drawPdfTable(doc, headers, rows, [55, 70, 50, 90, 70, 60, 70, 80, 60, 65, 40, 60]);
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
await new Promise<void>((resolve) => doc.on('end', resolve));
|
||||||
|
|
||||||
|
const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
|
||||||
|
file_content = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
file_content.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
file_name = `Daily_Report_${req.date}.pdf`;
|
||||||
|
} else {
|
||||||
|
file_content = await generateExcelDailyReport(roster, formattedDate, dayName, generatedDate, summary);
|
||||||
|
file_name = `Daily_Report_${req.date}.xlsx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file_content, file_name };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportAdminRangeReport = async (req: any) => {
|
||||||
|
const data = await getAdminRangeReport(req);
|
||||||
|
const report = data.report || [];
|
||||||
|
const fileType = req.fileType || 'excel';
|
||||||
|
|
||||||
|
let file_content: Uint8Array;
|
||||||
|
let file_name: string;
|
||||||
|
|
||||||
|
const formattedFromDate = new Date(req.fromDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
const formattedToDate = new Date(req.toDate + 'T00:00:00').toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
const generatedDate = new Date().toLocaleString('en-GB', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const dates = getDaysArray(req.fromDate, req.toDate);
|
||||||
|
|
||||||
|
if (fileType === 'pdf') {
|
||||||
|
const doc = new PDFDocument({ margin: 30, size: 'A4', layout: 'landscape' });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
doc.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
||||||
|
|
||||||
|
// PDF Top Level Headings
|
||||||
|
doc.fontSize(10).fillColor('#000000').text('Report Name : Monthly Attendance Report', { align: 'left' });
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Period : ${formattedFromDate} To ${formattedToDate}`, { align: 'left' });
|
||||||
|
doc.fontSize(10).fillColor('#000000').text(`Generated Date : ${generatedDate}`, { align: 'left' });
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
const numDates = dates.length;
|
||||||
|
const fieldColWidth = 50;
|
||||||
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const dateColWidth = Math.max(18, (pageWidth - fieldColWidth) / numDates);
|
||||||
|
|
||||||
|
let y = doc.y;
|
||||||
|
|
||||||
|
report.forEach((emp: any) => {
|
||||||
|
const history = emp.attendance_history || [];
|
||||||
|
|
||||||
|
// Check page break
|
||||||
|
if (y + 80 > doc.page.height - doc.page.margins.bottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = doc.page.margins.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Employee Summary Header ---
|
||||||
|
// Added "Name" and changed abbreviations to full words to match Excel logic
|
||||||
|
doc.font('Helvetica-Bold').fontSize(8).fillColor('#000000');
|
||||||
|
const summaryText = `Emp Code: ${emp.employee_code} | Name: ${emp.first_name} ${emp.last_name} | Company: ${emp.company_name || '-'} | Branch: ${emp.branch_name || '-'} | Dept: ${emp.department_name || '-'} | Role: ${emp.designation || '-'} | Full: ${emp.range_metrics?.full_days || 0} | Half: ${emp.range_metrics?.half_days || 0} | Mispunches: ${emp.range_metrics?.mispunches || 0} | Late: ${emp.range_metrics?.late_arrivals || 0}`;
|
||||||
|
doc.fill('#F2F2F2').rect(doc.page.margins.left, y, pageWidth, 18).fill();
|
||||||
|
doc.fillColor('#000000').text(summaryText, doc.page.margins.left + 5, y + 4, { width: pageWidth - 10 });
|
||||||
|
y += 20;
|
||||||
|
|
||||||
|
// --- Table Header (Field | Date 1 | Date 2 | ...) ---
|
||||||
|
doc.font('Helvetica-Bold').fontSize(6);
|
||||||
|
let x = doc.page.margins.left;
|
||||||
|
doc.fill('#063B00').rect(x, y, fieldColWidth, 16).fill();
|
||||||
|
doc.fillColor('#FFFFFF').text('Field', x + 2, y + 4, { width: fieldColWidth - 4, align: 'center' });
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, fieldColWidth, 16).stroke();
|
||||||
|
x += fieldColWidth;
|
||||||
|
|
||||||
|
for (let i = 0; i < numDates; i++) {
|
||||||
|
const dateObj = new Date(dates[i] + 'T00:00:00');
|
||||||
|
const dayStr = dateObj.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
||||||
|
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
doc.fill('#063B00').rect(x, y, dateColWidth, 16).fill();
|
||||||
|
doc.fillColor('#FFFFFF').text(dayStr, x + 1, y + 2, { width: dateColWidth - 2, align: 'center' });
|
||||||
|
doc.fillColor('#FFFFFF').text(dayName, x + 1, y + 8, { width: dateColWidth - 2, align: 'center' });
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(x, y, dateColWidth, 16).stroke();
|
||||||
|
x += dateColWidth;
|
||||||
|
}
|
||||||
|
y += 16;
|
||||||
|
|
||||||
|
// --- Data Rows (Check-In, Check-Out, Hours, Status) ---
|
||||||
|
const fields = ['Check-In', 'Check-Out', 'Hours', 'Status'];
|
||||||
|
doc.font('Helvetica').fontSize(6);
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
if (y + 14 > doc.page.height - doc.page.margins.bottom) {
|
||||||
|
doc.addPage();
|
||||||
|
y = doc.page.margins.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xRow = doc.page.margins.left;
|
||||||
|
doc.fillColor('#000000').text(field, xRow + 2, y + 3, { width: fieldColWidth - 4, align: 'left' });
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(xRow, y, fieldColWidth, 14).stroke();
|
||||||
|
xRow += fieldColWidth;
|
||||||
|
|
||||||
|
for (let i = 0; i < numDates; i++) {
|
||||||
|
const log = history.find((h: any) => h.work_date === dates[i]);
|
||||||
|
let text = '-';
|
||||||
|
let textColor = '#000000';
|
||||||
|
let bgColor = null;
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
if (field === 'Check-In') text = log.check_in ? log.check_in.substring(0, 5) : '-';
|
||||||
|
else if (field === 'Check-Out') text = log.check_out ? log.check_out.substring(0, 5) : '-';
|
||||||
|
else if (field === 'Hours') text = log.worked_hours ? Number(log.worked_hours).toFixed(2) : '0.00';
|
||||||
|
else if (field === 'Status') {
|
||||||
|
// Converted text to F, H, M, A, L while keeping the same colors
|
||||||
|
const statusStr = (log.final_status || '').toUpperCase();
|
||||||
|
if (statusStr === 'FULL_DAY') { text = 'F'; bgColor = '#C6EFCE'; textColor = '#006100'; }
|
||||||
|
else if (statusStr === 'ABSENT') { text = 'A'; bgColor = '#FFC7CE'; textColor = '#9C0006'; }
|
||||||
|
else if (statusStr === 'HALF_DAY') { text = 'H'; bgColor = '#FFEB9C'; textColor = '#9C6500'; }
|
||||||
|
else if (statusStr === 'MISPUNCH') { text = 'M'; bgColor = '#B1A0C7'; textColor = '#4B2E83'; }
|
||||||
|
else if (statusStr === 'LATE') { text = 'L'; bgColor = '#FFC000'; textColor = '#000000'; }
|
||||||
|
else { text = '-'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgColor) {
|
||||||
|
doc.fill(bgColor).rect(xRow, y, dateColWidth, 14).fill();
|
||||||
|
}
|
||||||
|
doc.fillColor(textColor).text(text, xRow + 1, y + 3, { width: dateColWidth - 2, align: 'center' });
|
||||||
|
doc.lineWidth(0.5).strokeColor('#000000').rect(xRow, y, dateColWidth, 14).stroke();
|
||||||
|
xRow += dateColWidth;
|
||||||
|
}
|
||||||
|
y += 14;
|
||||||
|
});
|
||||||
|
y += 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
await new Promise<void>((resolve) => doc.on('end', resolve));
|
||||||
|
|
||||||
|
const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
|
||||||
|
file_content = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
file_content.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
file_name = `Monthly_Report_${req.fromDate}_to_${req.toDate}.pdf`;
|
||||||
|
} else {
|
||||||
|
// Use the new Excel helper function for Matrix format
|
||||||
|
file_content = await generateExcelMonthlyReport(report, dates, formattedFromDate, formattedToDate, generatedDate);
|
||||||
|
file_name = `Monthly_Report_${req.fromDate}_to_${req.toDate}.xlsx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file_content, file_name };
|
||||||
|
};
|
||||||
102
services/ams/services/regularization.service.test.ts
Normal file
102
services/ams/services/regularization.service.test.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// services/ams/services/regularization.service.test.ts
|
||||||
|
import { assertEquals, assertRejects} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||||||
|
import { stub } from "https://deno.land/std@0.224.0/testing/mock.ts";
|
||||||
|
import * as RegService from "./regularization.service.ts";
|
||||||
|
import { __deps } from "./regularization.service.ts";
|
||||||
|
import { formatDateTime } from "./regularization.service.ts";
|
||||||
|
|
||||||
|
|
||||||
|
Deno.test("createRegularizationRequest should throw error if target_date is a Sunday", async () => {
|
||||||
|
const payload = {
|
||||||
|
employee_id: 135,
|
||||||
|
reason: "Forgot to punch",
|
||||||
|
regularization_type: "MISPUNCH",
|
||||||
|
target_date: "2026-07-12", // This is a Sunday
|
||||||
|
};
|
||||||
|
|
||||||
|
// We expect this promise to reject with a specific error
|
||||||
|
await assertRejects(
|
||||||
|
() => RegService.createRegularizationRequest(payload),
|
||||||
|
Error,
|
||||||
|
"Cannot apply for regularization on a weekly off (Sunday)."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("createRegularizationRequest should throw error if missing required fields", async () => {
|
||||||
|
const payload = {
|
||||||
|
employee_id: 135,
|
||||||
|
// missing reason and target_date
|
||||||
|
};
|
||||||
|
|
||||||
|
await assertRejects(
|
||||||
|
() => RegService.createRegularizationRequest(payload as any),
|
||||||
|
Error,
|
||||||
|
"Missing required fields."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("createRegularizationRequest should throw error if employee is not found in EMS", async () => {
|
||||||
|
const payload = {
|
||||||
|
employee_id: 999,
|
||||||
|
reason: "Forgot to punch",
|
||||||
|
regularization_type: "MISPUNCH",
|
||||||
|
target_date: "2026-07-15",
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIX: Stub the method on the __deps object
|
||||||
|
const rosterStub = stub(__deps, "getEmployeeRoster", () => Promise.resolve([]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertRejects(
|
||||||
|
() => RegService.createRegularizationRequest(payload),
|
||||||
|
Error,
|
||||||
|
"Employee not found in EMS."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rosterStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("createRegularizationRequest should succeed and insert into DB for valid data", async () => {
|
||||||
|
const payload = {
|
||||||
|
employee_id: 135,
|
||||||
|
reason: "Forgot to punch",
|
||||||
|
regularization_type: "MISPUNCH",
|
||||||
|
target_date: "2026-07-15",
|
||||||
|
requested_check_in: "2026-07-15 10:00:00",
|
||||||
|
requested_check_out: "2026-07-15 19:00:00"
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIX: Stub the methods on the __deps object
|
||||||
|
const rosterStub = stub(__deps, "getEmployeeRoster", () =>
|
||||||
|
Promise.resolve([{ employeeId: 135, branchId: 1 } as any])
|
||||||
|
);
|
||||||
|
|
||||||
|
let insertedData: any = null;
|
||||||
|
const insertStub = stub(__deps, "insertRegularization", async (data: any) => {
|
||||||
|
insertedData = data;
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await RegService.createRegularizationRequest(payload);
|
||||||
|
|
||||||
|
assertEquals(insertedData.employee_id, 135);
|
||||||
|
assertEquals(insertedData.branch_id, 1);
|
||||||
|
assertEquals(insertedData.target_date, "2026-07-15");
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
rosterStub.restore();
|
||||||
|
insertStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("formatDateTime should not shift the date forward due to timezone", () => {
|
||||||
|
// Simulate MySQL returning a Date object for "2026-07-12 18:30:00" local time
|
||||||
|
const mysqlDateObject = new Date(2026, 6, 12, 18, 30, 0); // Month is 0-indexed (6 = July)
|
||||||
|
|
||||||
|
const formattedString = formatDateTime(mysqlDateObject);
|
||||||
|
|
||||||
|
// Assert it stays on the 12th, doesn't shift to the 13th
|
||||||
|
assertEquals(formattedString?.startsWith("2026-07-12"), true);
|
||||||
|
});
|
||||||
208
services/ams/services/regularization.service.ts
Normal file
208
services/ams/services/regularization.service.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// services/ams/services/regularization.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import { AppRole } from "core/auth.ts";
|
||||||
|
//import { getEmployeeRoster } from "core/internal-client.ts";
|
||||||
|
import * as InternalClient from "core/internal-client.ts"; // CHANGED TO NAMESPACE
|
||||||
|
import * as RegRepo from "../repositories/regularization.repository.ts";
|
||||||
|
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
|
||||||
|
|
||||||
|
// FIX: Dependency Injection Object for test mocking
|
||||||
|
export const __deps = {
|
||||||
|
getEmployeeRoster: InternalClient.getEmployeeRoster,
|
||||||
|
insertRegularization: RegRepo.insertRegularization,
|
||||||
|
findPendingRegularizations: RegRepo.findPendingRegularizations,
|
||||||
|
findAllRegularizations: RegRepo.findAllRegularizations,
|
||||||
|
findRegularizationById: RegRepo.findRegularizationById,
|
||||||
|
updateRegularizationStatus: RegRepo.updateRegularizationStatus,
|
||||||
|
findShiftDetails: AttendanceRepo.findShiftDetails,
|
||||||
|
updateProcessedAttendanceById: AttendanceRepo.updateProcessedAttendanceById,
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIX: Timezone safe formatter for recalculating attendance
|
||||||
|
export const formatDateTime = (dateVal: any): string | undefined => {
|
||||||
|
if (!dateVal) return undefined;
|
||||||
|
if (dateVal instanceof Date) {
|
||||||
|
const offset = dateVal.getTimezoneOffset() * 60000;
|
||||||
|
const localDate = new Date(dateVal.getTime() - offset);
|
||||||
|
return localDate.toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
}
|
||||||
|
return String(dateVal).replace("T", " ").substring(0, 19);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRegularizationRequest = async (data: any) => {
|
||||||
|
if (!data.employee_id || !data.reason || !data.regularization_type || !data.target_date) {
|
||||||
|
throw new Error("Missing required fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Weekend validation check
|
||||||
|
const targetDateObj = new Date(data.target_date);
|
||||||
|
const dayOfWeek = targetDateObj.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
|
|
||||||
|
// Assuming Sunday (0) is the default weekly off.
|
||||||
|
if (dayOfWeek === 0) {
|
||||||
|
throw new Error("Cannot apply for regularization on a weekly off (Sunday).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate requested check-in date matches target date
|
||||||
|
if (data.requested_check_in) {
|
||||||
|
const checkInDate = new Date(data.requested_check_in).toISOString().split('T')[0];
|
||||||
|
if (checkInDate !== data.target_date) {
|
||||||
|
throw new Error("Requested Check-In date does not match the Target Date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roster = await __deps.getEmployeeRoster([data.employee_id]);
|
||||||
|
if (roster.length === 0) throw new Error("Employee not found in EMS.");
|
||||||
|
|
||||||
|
const branch_id = roster[0].branchId || 0;
|
||||||
|
|
||||||
|
await __deps.insertRegularization({ ...data, branch_id });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPendingRegularizations = async (user: any) => {
|
||||||
|
let branchIds: number[] | null = null;
|
||||||
|
if (user.role !== AppRole.DIRECTOR) {
|
||||||
|
branchIds = user.managed_branches;
|
||||||
|
if (!branchIds || branchIds.length === 0) return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await __deps.findPendingRegularizations(branchIds);
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const employeeIds = [...new Set(rows.map((r: any) => Number(r.employee_id)))] as number[];
|
||||||
|
const roster = await __deps.getEmployeeRoster(employeeIds);
|
||||||
|
|
||||||
|
const rosterMap = new Map(roster.map((e: any) => [e.employeeId, e]));
|
||||||
|
|
||||||
|
return rows.map((req: any) => {
|
||||||
|
const emp = rosterMap.get(req.employee_id);
|
||||||
|
return {
|
||||||
|
...req,
|
||||||
|
employee_code: emp?.employeeCode || "N/A",
|
||||||
|
first_name: emp?.fullName?.split(" ")[0] || "",
|
||||||
|
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
|
||||||
|
branch_name: emp?.branchName || "N/A",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllRegularizations = async () => {
|
||||||
|
const rows = await __deps.findAllRegularizations();
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const allIds = new Set<number>();
|
||||||
|
rows.forEach((r: any) => {
|
||||||
|
if (r.employee_id) allIds.add(Number(r.employee_id));
|
||||||
|
if (r.reviewed_by_id) allIds.add(Number(r.reviewed_by_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const roster = await __deps.getEmployeeRoster(Array.from(allIds));
|
||||||
|
const rosterMap = new Map(roster.map((e: any) => [e.employeeId, e]));
|
||||||
|
|
||||||
|
return rows.map((req: any) => {
|
||||||
|
const emp = rosterMap.get(Number(req.employee_id));
|
||||||
|
const reviewer = rosterMap.get(Number(req.reviewed_by_id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...req,
|
||||||
|
employee_code: emp?.employeeCode || "N/A",
|
||||||
|
first_name: emp?.fullName?.split(" ")[0] || "",
|
||||||
|
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
|
||||||
|
branch_name: emp?.branchName || "N/A",
|
||||||
|
reviewer_name: reviewer?.fullName || "Pending Review"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reviewRegularizationRequest = async (user: any, data: any) => {
|
||||||
|
if (!data.regularization_id || !["APPROVED", "REJECTED"].includes(data.action)) {
|
||||||
|
throw new Error("Invalid payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const request = await __deps.findRegularizationById(data.regularization_id, connection);
|
||||||
|
|
||||||
|
if (!request) throw new Error("Regularization request not found.");
|
||||||
|
|
||||||
|
if (user.role !== AppRole.DIRECTOR) {
|
||||||
|
if (user.role !== AppRole.HR_MANAGER) {
|
||||||
|
throw new Error("Access Denied: Only HR Managers and Directors can review requests.");
|
||||||
|
}
|
||||||
|
if (!user.managed_branches.includes(request.branch_id)) {
|
||||||
|
throw new Error("Access Denied: You do not manage this branch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== "PENDING") throw new Error("This request has already been processed.");
|
||||||
|
|
||||||
|
// FIX: Use __deps
|
||||||
|
await __deps.updateRegularizationStatus(data.regularization_id, data.action, user.employee_id, connection);
|
||||||
|
|
||||||
|
if (data.action === "APPROVED") {
|
||||||
|
let worked_hours = 0.0, final_status = "FULL_DAY", check_in_status = "ON_TIME";
|
||||||
|
|
||||||
|
const rawCheckIn = formatDateTime(request.requested_check_in);
|
||||||
|
const rawCheckOut = formatDateTime(request.requested_check_out);
|
||||||
|
|
||||||
|
// FIX: Use __deps
|
||||||
|
const roster = await __deps.getEmployeeRoster([request.employee_id]);
|
||||||
|
const emp = roster[0];
|
||||||
|
|
||||||
|
if (emp) {
|
||||||
|
// FIX: Use __deps
|
||||||
|
const shift = await __deps.findShiftDetails(emp.branchId, emp.companyId, connection);
|
||||||
|
|
||||||
|
if (rawCheckIn && rawCheckOut) {
|
||||||
|
const checkInMs = new Date(rawCheckIn.replace(" ", "T")).getTime();
|
||||||
|
const checkOutMs = new Date(rawCheckOut.replace(" ", "T")).getTime();
|
||||||
|
const effectiveCheckOutMs = checkOutMs < checkInMs ? checkOutMs + (24 * 60 * 60 * 1000) : checkOutMs;
|
||||||
|
worked_hours = Math.round(((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100) / 100;
|
||||||
|
|
||||||
|
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
||||||
|
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
||||||
|
else final_status = "ABSENT";
|
||||||
|
|
||||||
|
const timePart = rawCheckIn.split(" ")[1];
|
||||||
|
const [punchH, punchM, punchS] = timePart.split(":").map(Number);
|
||||||
|
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(Number);
|
||||||
|
|
||||||
|
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
||||||
|
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (shift.grace_period_minutes * 60);
|
||||||
|
|
||||||
|
if (punchTotalSeconds > shiftCutoffSeconds) check_in_status = "LATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
let attendanceIdToUpdate = request.attendance_id;
|
||||||
|
if (!attendanceIdToUpdate) {
|
||||||
|
const [attRows]: any[] = await connection.execute(
|
||||||
|
`SELECT attendance_id FROM processed_daily_attendance WHERE employee_id = ? AND work_date = ?`,
|
||||||
|
[request.employee_id, request.target_date]
|
||||||
|
);
|
||||||
|
if (attRows.length > 0) attendanceIdToUpdate = attRows[0].attendance_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attendanceIdToUpdate && (rawCheckIn || rawCheckOut)) {
|
||||||
|
// FIX: Use __deps
|
||||||
|
await __deps.updateProcessedAttendanceById(attendanceIdToUpdate, {
|
||||||
|
requested_check_in: rawCheckIn,
|
||||||
|
requested_check_out: rawCheckOut,
|
||||||
|
worked_hours,
|
||||||
|
final_status,
|
||||||
|
check_in_status
|
||||||
|
}, connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
15
services/ems/db.ts
Normal file
15
services/ems/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// services/ems/db.ts
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
||||||
|
port: Number(Deno.env.get("EMS_DB_PORT")) || 3306,
|
||||||
|
user: Deno.env.get("DB_USER") || "admin",
|
||||||
|
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||||
|
database: "hrms_ems",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
37
services/ems/handlers/contract.handler.ts
Normal file
37
services/ems/handlers/contract.handler.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// services/ems/contract-handlers.ts
|
||||||
|
import * as ContractService from "../services/contract.service.ts";
|
||||||
|
|
||||||
|
export const ContractServiceImplementation = {
|
||||||
|
async getEmployeeContracts(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await ContractService.getEmployeeContracts(Number(req.id));
|
||||||
|
const mappedData = data.map((c: any) => ({
|
||||||
|
termId: Number(c.term_id) || 0,
|
||||||
|
workEmail: c.work_email || "",
|
||||||
|
dateJoining: c.date_joining ? new Date(c.date_joining).toISOString().split('T')[0] : "",
|
||||||
|
probationDays: Number(c.probation_days) || 0,
|
||||||
|
status: c.status || "",
|
||||||
|
salaryStructureId: Number(c.salary_structure_id) || 0,
|
||||||
|
assignmentId: Number(c.assignment_id) || 0,
|
||||||
|
changeReason: c.change_reason || "",
|
||||||
|
effectiveFrom: c.effective_from ? new Date(c.effective_from).toISOString().split('T')[0] : "",
|
||||||
|
effectiveTo: c.effective_to ? new Date(c.effective_to).toISOString().split('T')[0] : "",
|
||||||
|
isCurrent: !!c.is_current,
|
||||||
|
department: c.department || "",
|
||||||
|
designation: c.designation || ""
|
||||||
|
}));
|
||||||
|
return { success: true, count: Number(mappedData.length), data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, count: 0, data: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createContract(req: any) {
|
||||||
|
try {
|
||||||
|
const result = await ContractService.createContract(req);
|
||||||
|
return { success: true, message: "New assignment executed successfully", assignmentId: result.assignment_id };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message, assignmentId: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
26
services/ems/handlers/dashboard.handler.ts
Normal file
26
services/ems/handlers/dashboard.handler.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// services/ems/dashboard-handlers.ts
|
||||||
|
import * as DashboardService from "../services/dashboard.service.ts";
|
||||||
|
|
||||||
|
const mapToParams = (obj: any) => {
|
||||||
|
return {
|
||||||
|
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DashboardServiceImplementation = {
|
||||||
|
async getDashboardMetrics(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await DashboardService.getDashboardMetrics(mapToParams(req));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
totalCompanies: Number(data.total_companies) || 0,
|
||||||
|
totalBranches: Number(data.total_branches) || 0,
|
||||||
|
totalDepartments: Number(data.total_departments) || 0,
|
||||||
|
totalJobs: Number(data.total_jobs) || 0,
|
||||||
|
totalEmployees: Number(data.total_employees) || 0
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, totalCompanies: 0, totalBranches: 0, totalDepartments: 0, totalJobs: 0, totalEmployees: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
213
services/ems/handlers/employee.handler.ts
Normal file
213
services/ems/handlers/employee.handler.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// services/ems/grpc-handlers.ts
|
||||||
|
import * as EmployeeService from "../services/employee.service.ts";
|
||||||
|
import * as InternalService from "../services/internal.service.ts";
|
||||||
|
|
||||||
|
const mapToParams = (obj: any) => {
|
||||||
|
return {
|
||||||
|
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmployeeServiceImplementation = {
|
||||||
|
async getEmployees(req: any) {
|
||||||
|
try {
|
||||||
|
const params = mapToParams(req);
|
||||||
|
const data = await EmployeeService.getEmployees(params);
|
||||||
|
const mappedData = data.map((emp: any) => ({
|
||||||
|
employeeId: Number(emp.employee_id) || 0,
|
||||||
|
employeeCode: emp.employee_code || "",
|
||||||
|
isActive: !!emp.is_active,
|
||||||
|
firstName: emp.first_name || "",
|
||||||
|
lastName: emp.last_name || "",
|
||||||
|
workEmail: emp.work_email || "",
|
||||||
|
jobName: emp.job_name || "",
|
||||||
|
departmentName: emp.department_name || "",
|
||||||
|
branchName: emp.branch_name || "",
|
||||||
|
companyName: emp.company_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, count: Number(mappedData.length), data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getEmployees Error:", error);
|
||||||
|
return { success: false, count: 0, data: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEmployeeById(req: any) {
|
||||||
|
try {
|
||||||
|
const emp = await EmployeeService.getEmployeeById(Number(req.id));
|
||||||
|
|
||||||
|
// Helper to format MySQL Date objects to YYYY-MM-DD strings
|
||||||
|
const formatDate = (dateVal: any) => {
|
||||||
|
if (!dateVal) return "";
|
||||||
|
return dateVal instanceof Date ? dateVal.toISOString().split('T')[0] : String(dateVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The service layer returns: emp.contract = { work_email, date_joining, department, job_id, manager, etc. }
|
||||||
|
// We need to flatten it to match the Protobuf EmployeeDetail message.
|
||||||
|
const mappedData = {
|
||||||
|
employeeId: Number(emp.employee_id) || 0,
|
||||||
|
employeeCode: emp.employee_code || "",
|
||||||
|
isActive: !!emp.is_active,
|
||||||
|
firstName: emp.first_name || "",
|
||||||
|
lastName: emp.last_name || "",
|
||||||
|
dob: formatDate(emp.dob),
|
||||||
|
gender: emp.gender || "",
|
||||||
|
personalEmail: emp.personal_email || "",
|
||||||
|
personalPhone: emp.personal_phone || "",
|
||||||
|
workEmail: emp.contract?.work_email || "",
|
||||||
|
dateJoining: formatDate(emp.contract?.date_joining),
|
||||||
|
probationDays: Number(emp.contract?.probation_days) || 0,
|
||||||
|
contractStatus: emp.contract?.status || "",
|
||||||
|
salaryStructureId: Number(emp.contract?.salary_structure_id) || 0,
|
||||||
|
companyName: emp.company_name || "", // Pulled from DB query via JOIN
|
||||||
|
companyId: Number(emp.company_id) || 0,
|
||||||
|
branchName: emp.branch_name || "", // Pulled from DB query via JOIN
|
||||||
|
branchId: Number(emp.branch_id) || 0,
|
||||||
|
department: emp.contract?.department || "",
|
||||||
|
departmentId: Number(emp.contract?.department_id) || 0,
|
||||||
|
jobName: emp.contract?.designation || "", // Mapped from designation
|
||||||
|
jobId: Number(emp.contract?.job_id) || 0,
|
||||||
|
managerFirstName: emp.contract?.manager?.first_name || "",
|
||||||
|
managerLastName: emp.contract?.manager?.last_name || "",
|
||||||
|
managerEmployeeCode: emp.contract?.manager?.employee_code || "",
|
||||||
|
addresses: (emp.addresses || []).map((a: any) => ({
|
||||||
|
type: a.address_type || "",
|
||||||
|
doorNumber: a.door_number || "",
|
||||||
|
landmark: a.landmark || "",
|
||||||
|
line: a.address_line || "",
|
||||||
|
pincode: a.pincode || "",
|
||||||
|
district: a.district || "",
|
||||||
|
state: a.state || ""
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("gRPC getEmployeeById Error:", error);
|
||||||
|
return { success: false, data: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEmployeeHistory(req: any) {
|
||||||
|
try {
|
||||||
|
const history = await EmployeeService.getEmployeeHistory(Number(req.id));
|
||||||
|
const mappedHistory = history.map((h: any) => ({
|
||||||
|
assignmentId: Number(h.assignment_id) || 0,
|
||||||
|
changeReason: h.change_reason || "",
|
||||||
|
effectiveFrom: h.effective_from || "",
|
||||||
|
effectiveTo: h.effective_to || "",
|
||||||
|
isCurrent: !!h.is_current,
|
||||||
|
department: h.department || "",
|
||||||
|
jobTitle: h.job_title || "",
|
||||||
|
managerFirstName: h.manager_first_name || "",
|
||||||
|
managerLastName: h.manager_last_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, history: mappedHistory };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, history: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createEmployee(req: any) {
|
||||||
|
try {
|
||||||
|
const dbPayload = {
|
||||||
|
firstName: req.firstName,
|
||||||
|
lastName: req.lastName,
|
||||||
|
dob: req.dob,
|
||||||
|
gender: req.gender,
|
||||||
|
personalEmail: req.personalEmail,
|
||||||
|
personalPhone: req.personalPhone,
|
||||||
|
address: req.address,
|
||||||
|
companyId: req.companyId,
|
||||||
|
branchId: req.branchId,
|
||||||
|
departmentId: req.departmentId,
|
||||||
|
jobId: req.jobId,
|
||||||
|
work_email: req.workEmail,
|
||||||
|
dateJoining: req.dateJoining,
|
||||||
|
probationDays: req.probationDays,
|
||||||
|
salaryStructureId: req.salaryStructureId,
|
||||||
|
reportingToId: req.reportingToId || null,
|
||||||
|
};
|
||||||
|
const result = await EmployeeService.createEmployee(dbPayload);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Employee created",
|
||||||
|
employeeId: result.employee_id,
|
||||||
|
employeeCode: result.employee_code
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message, employeeId: 0, employeeCode: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEmployee(req: any) {
|
||||||
|
try {
|
||||||
|
const dbPayload = {
|
||||||
|
firstName: req.data?.firstName,
|
||||||
|
lastName: req.data?.lastName,
|
||||||
|
dob: req.data?.dob,
|
||||||
|
gender: req.data?.gender,
|
||||||
|
personalEmail: req.data?.personalEmail,
|
||||||
|
personalPhone: req.data?.personalPhone,
|
||||||
|
address: req.data?.address,
|
||||||
|
companyId: req.data?.companyId,
|
||||||
|
branchId: req.data?.branchId,
|
||||||
|
departmentId: req.data?.departmentId,
|
||||||
|
jobId: req.data?.jobId,
|
||||||
|
work_email: req.data?.workEmail,
|
||||||
|
dateJoining: req.data?.dateJoining,
|
||||||
|
probationDays: req.data?.probationDays,
|
||||||
|
salaryStructureId: req.data?.salaryStructureId,
|
||||||
|
reportingToId: req.data?.reportingToId || null,
|
||||||
|
isActive: req.data?.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture the returned message from the service layer
|
||||||
|
const message = await EmployeeService.updateEmployee(Number(req.id), dbPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: message // Use the dynamic message here!
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InternalServiceImplementation = {
|
||||||
|
async getRoster(req: any) {
|
||||||
|
try {
|
||||||
|
const ids = req.ids || [];
|
||||||
|
const data = await InternalService.getRoster(ids.length > 0 ? ids.join(",") : undefined);
|
||||||
|
const mappedData = data.map((emp: any) => ({
|
||||||
|
employeeId: Number(emp.employee_id) || 0,
|
||||||
|
employeeCode: emp.employee_code || "",
|
||||||
|
isActive: !!emp.is_active,
|
||||||
|
fullName: emp.full_name || "",
|
||||||
|
branchId: Number(emp.branch_id) || 0,
|
||||||
|
branchName: emp.branch_name || "",
|
||||||
|
companyId: Number(emp.company_id) || 0,
|
||||||
|
companyName: emp.company_name || "",
|
||||||
|
departmentName: emp.department_name || "",
|
||||||
|
departmentId: Number(emp.department_id) || 0,
|
||||||
|
jobTitle: emp.job_title || "",
|
||||||
|
managerName: emp.manager_name || "No Manager Assigned",
|
||||||
|
managerId: Number(emp.manager_id) || 0
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getManager(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await InternalService.getManager(Number(req.id));
|
||||||
|
return { success: true, managerId: Number(data.manager_id) || 0 };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, managerId: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
101
services/ems/handlers/lookup.handler.ts
Normal file
101
services/ems/handlers/lookup.handler.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
// services/ems/lookup-handlers.ts
|
||||||
|
import * as LookupService from "../services/lookup.service.ts";
|
||||||
|
|
||||||
|
const mapToParams = (obj: any) => {
|
||||||
|
return {
|
||||||
|
get: (key: string) => obj[key] !== undefined ? String(obj[key]) : null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LookupServiceImplementation = {
|
||||||
|
// Companies
|
||||||
|
async getCompanies(req: any) {
|
||||||
|
const data = await LookupService.getCompanies(mapToParams(req));
|
||||||
|
return { success: true, data: data.map((c: any) => ({ companyId: c.company_id, companyCode: c.company_code, name: c.name, isActive: !!c.is_active })) };
|
||||||
|
},
|
||||||
|
async createCompany(req: any) {
|
||||||
|
try {
|
||||||
|
const result = await LookupService.createCompany(req);
|
||||||
|
return { success: true, message: "Company created", id: result.company_id, code: result.company_code };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
|
||||||
|
},
|
||||||
|
async updateCompany(req: any) {
|
||||||
|
try { await LookupService.updateCompany(req.id, req.companyData); return { success: true, message: "Updated" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async deleteCompany(req: any) {
|
||||||
|
try { await LookupService.deleteCompany(req.id); return { success: true, message: "Deleted" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Branches
|
||||||
|
async getBranches(req: any) {
|
||||||
|
const data = await LookupService.getBranches(mapToParams(req));
|
||||||
|
return { success: true, data: data.map((b: any) => ({ branchId: b.branch_id, code: b.code, branchName: b.branch_name, isActive: !!b.is_active, companyCode: b.company_code, companyName: b.company_name })) };
|
||||||
|
},
|
||||||
|
async createBranch(req: any) {
|
||||||
|
try { const r = await LookupService.createBranch(req); return { success: true, message: "Branch created", id: r.branch_id, code: r.code }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
|
||||||
|
},
|
||||||
|
async updateBranch(req: any) {
|
||||||
|
try { await LookupService.updateBranch(req.id, req.branchData); return { success: true, message: "Updated" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async deleteBranch(req: any) {
|
||||||
|
try { await LookupService.deleteBranch(req.id); return { success: true, message: "Deleted" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Departments
|
||||||
|
async getDepartments(req: any) {
|
||||||
|
const data = await LookupService.getDepartments(mapToParams(req));
|
||||||
|
return { success: true, data: data.map((d: any) => ({ departmentId: d.department_id, departmentCode: d.department_code, departmentName: d.department_name || "", isActive: !!d.is_active, branchName: d.branch_name, companyName: d.company_name, managers: d.managers || "" })) };
|
||||||
|
},
|
||||||
|
async createDepartment(req: any) {
|
||||||
|
try { const r = await LookupService.createDepartment(req); return { success: true, message: "Dept created", id: r.department_id, code: r.department_code }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
|
||||||
|
},
|
||||||
|
async updateDepartment(req: any) {
|
||||||
|
try { await LookupService.updateDepartment(req.id, req.departmentData); return { success: true, message: "Updated" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async deleteDepartment(req: any) {
|
||||||
|
try { await LookupService.deleteDepartment(req.id); return { success: true, message: "Deleted" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
async getJobs(req: any) {
|
||||||
|
const data = await LookupService.getJobs(mapToParams(req));
|
||||||
|
return { success: true, data: data.map((j: any) => ({ jobId: j.job_id, jobCode: j.job_code, jobName: j.job_name || "", isActive: !!j.is_active, departmentName: j.department_name, branchName: j.branch_name, companyName: j.company_name, description: j.description || "" })) };
|
||||||
|
},
|
||||||
|
async createJob(req: any) {
|
||||||
|
try { const r = await LookupService.createJob(req); return { success: true, message: "Job created", id: r.job_id, code: r.job_code }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message, id: 0, code: "" }; }
|
||||||
|
},
|
||||||
|
async updateJob(req: any) {
|
||||||
|
try { await LookupService.updateJob(req.id, req.jobData); return { success: true, message: "Updated" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async deleteJob(req: any) {
|
||||||
|
try { await LookupService.deleteJob(req.id); return { success: true, message: "Deleted" }; }
|
||||||
|
catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
// NEW: Department Managers
|
||||||
|
async getDepartmentManagers(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await LookupService.getDepartmentManagers(Number(req.departmentId));
|
||||||
|
const mappedData = data.map((m: any) => ({
|
||||||
|
employeeId: Number(m.employee_id) || 0,
|
||||||
|
employeeCode: m.employee_code || "",
|
||||||
|
firstName: m.first_name || "",
|
||||||
|
lastName: m.last_name || "",
|
||||||
|
departmentId: Number(m.department_id) || 0,
|
||||||
|
departmentName: m.department_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
36
services/ems/main.ts
Normal file
36
services/ems/main.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// services/ems/main.ts
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import { EmployeeServiceService, InternalServiceService, LookupServiceService, ContractServiceService, DashboardServiceService } from "../../generated/ems.ts";
|
||||||
|
import { EmployeeServiceImplementation, InternalServiceImplementation } from "./handlers/employee.handler.ts";
|
||||||
|
import { LookupServiceImplementation } from "./handlers/lookup.handler.ts";
|
||||||
|
import { ContractServiceImplementation } from "./handlers/contract.handler.ts";
|
||||||
|
import { DashboardServiceImplementation } from "./handlers/dashboard.handler.ts";
|
||||||
|
|
||||||
|
const PORT = Number(Deno.env.get("EMS_PORT")) || 8001;
|
||||||
|
|
||||||
|
const wrap = (impl: any) => {
|
||||||
|
const wrapped: any = {};
|
||||||
|
for (const key in impl) {
|
||||||
|
wrapped[key] = async (call: any, callback: any) => {
|
||||||
|
try {
|
||||||
|
const res = await impl[key](call.request);
|
||||||
|
callback(null, res);
|
||||||
|
} catch (err: any) {
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new grpc.Server();
|
||||||
|
server.addService(EmployeeServiceService, wrap(EmployeeServiceImplementation));
|
||||||
|
server.addService(InternalServiceService, wrap(InternalServiceImplementation));
|
||||||
|
server.addService(LookupServiceService, wrap(LookupServiceImplementation));
|
||||||
|
server.addService(ContractServiceService, wrap(ContractServiceImplementation));
|
||||||
|
server.addService(DashboardServiceService, wrap(DashboardServiceImplementation));
|
||||||
|
|
||||||
|
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
|
||||||
|
if (err) { console.error("Failed to start gRPC server:", err); return; }
|
||||||
|
console.log(`🚀 EMS gRPC Service running on port ${port}`);
|
||||||
|
});
|
||||||
35
services/ems/repositories/contract.repository.ts
Normal file
35
services/ems/repositories/contract.repository.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// services/ems/repositories/contract.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const findContractsByEmployeeId = async (employeeId: number) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
et.term_id, et.work_email, et.date_joining, et.probation_days, et.status, et.salary_structure_id,
|
||||||
|
ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
|
||||||
|
d.name AS department, j.title AS designation
|
||||||
|
FROM employment_terms et
|
||||||
|
JOIN employee_assignments ea ON et.employee_id = ea.employee_id
|
||||||
|
JOIN departments d ON ea.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON ea.job_id = j.job_id
|
||||||
|
WHERE et.employee_id = ?
|
||||||
|
ORDER BY et.date_joining DESC, ea.effective_from DESC`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expireCurrentAssignment = async (employeeId: number, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertNewAssignment = async (data: any, conn: any) => {
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
|
||||||
|
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
47
services/ems/repositories/dashboard.repository.ts
Normal file
47
services/ems/repositories/dashboard.repository.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// services/ems/repositories/dashboard.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
const buildFilters = (filters: any) => {
|
||||||
|
let clause = " WHERE 1=1";
|
||||||
|
const values: any[] = [];
|
||||||
|
if (filters.company_id) { clause += " AND company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { clause += " AND branch_id = ?"; values.push(filters.branch_id); }
|
||||||
|
return { clause, values };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countCompanies = async () => {
|
||||||
|
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM companies`);
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countBranches = async (filters: any) => {
|
||||||
|
const { clause, values } = buildFilters(filters);
|
||||||
|
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM branches ${clause}`, values);
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countDepartments = async (filters: any) => {
|
||||||
|
const { clause, values } = buildFilters(filters);
|
||||||
|
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM departments ${clause}`, values);
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countJobs = async (filters: any) => {
|
||||||
|
let clause = " WHERE 1=1";
|
||||||
|
const values: any[] = [];
|
||||||
|
if (filters.company_id) { clause += " AND d.company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { clause += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
||||||
|
if (filters.department_id) { clause += " AND j.department_id = ?"; values.push(filters.department_id); }
|
||||||
|
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM job_positions j JOIN departments d ON j.department_id = d.department_id ${clause}`, values);
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countEmployees = async (filters: any) => {
|
||||||
|
let clause = " WHERE e.is_active = TRUE";
|
||||||
|
const values: any[] = [];
|
||||||
|
if (filters.company_id) { clause += " AND e.company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { clause += " AND e.branch_id = ?"; values.push(filters.branch_id); }
|
||||||
|
if (filters.department_id) { clause += " AND ea.department_id = ?"; values.push(filters.department_id); }
|
||||||
|
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM employees e JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE ${clause}`, values);
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
194
services/ems/repositories/employee.repository.ts
Normal file
194
services/ems/repositories/employee.repository.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
// services/ems/repositories/employee.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const findEmployees = async (filters: any) => {
|
||||||
|
let query = `SELECT e.employee_id, e.employee_code, e.is_active,
|
||||||
|
p.first_name, p.last_name,
|
||||||
|
et.work_email, j.title AS job_name, d.name AS department_name,
|
||||||
|
b.branch_name, c.name AS company_name
|
||||||
|
FROM employees e
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN companies c ON e.company_id = c.company_id
|
||||||
|
JOIN branches b ON e.branch_id = b.branch_id
|
||||||
|
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
|
||||||
|
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
||||||
|
JOIN departments d ON ea.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON ea.job_id = j.job_id
|
||||||
|
WHERE 1=1`;
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (filters.company_id) { query += ` AND e.company_id = ?`; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { query += ` AND e.branch_id = ?`; values.push(filters.branch_id); }
|
||||||
|
if (filters.department_id) { query += ` AND ea.department_id = ?`; values.push(filters.department_id); }
|
||||||
|
if (filters.is_active !== undefined) { query += ` AND e.is_active = ?`; values.push(filters.is_active); }
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findEmployeeById = async (id: number) => {
|
||||||
|
const [empRows] = await pool.query(
|
||||||
|
`SELECT e.employee_id, e.employee_code, e.is_active, p.first_name, p.last_name, p.dob, p.gender,
|
||||||
|
p.personal_email, p.personal_phone, et.work_email, et.date_joining, et.probation_days,
|
||||||
|
et.status AS contract_status, et.salary_structure_id,
|
||||||
|
d.name AS department, d.department_id,
|
||||||
|
j.title AS job_name, j.job_id,
|
||||||
|
c.name AS company_name, c.company_id,
|
||||||
|
b.branch_name, b.branch_id,
|
||||||
|
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name,
|
||||||
|
mgr_e.employee_code AS manager_employee_code
|
||||||
|
FROM employees e
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN companies c ON e.company_id = c.company_id
|
||||||
|
JOIN branches b ON e.branch_id = b.branch_id
|
||||||
|
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
|
||||||
|
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
||||||
|
JOIN departments d ON ea.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON ea.job_id = j.job_id
|
||||||
|
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
|
||||||
|
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
||||||
|
WHERE e.employee_id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return (empRows as any[])[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAddressesByPartnerId = async (partnerId: number) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
|
||||||
|
FROM addresses WHERE partner_id = ?`,
|
||||||
|
[partnerId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findEmployeeHistory = async (id: number) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
|
||||||
|
d.name AS department, j.title AS job_title,
|
||||||
|
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name
|
||||||
|
FROM employee_assignments ea
|
||||||
|
JOIN departments d ON ea.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON ea.job_id = j.job_id
|
||||||
|
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
|
||||||
|
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
||||||
|
WHERE ea.employee_id = ?
|
||||||
|
ORDER BY ea.effective_from DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPartnerIdByEmployeeId = async (employeeId: number) => {
|
||||||
|
const [rows] = await pool.query(`SELECT partner_id FROM employees WHERE employee_id = ?`, [employeeId]);
|
||||||
|
return (rows as any[])[0]?.partner_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertPartner = async (data: any, conn: any) => {
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertAddress = async (partnerId: number, data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
door_number = VALUES(door_number), landmark = VALUES(landmark), address_line = VALUES(address_line),
|
||||||
|
pincode = VALUES(pincode), district = VALUES(district), state = VALUES(state)`,
|
||||||
|
[partnerId, data.type, data.doorNumber, data.landmark, data.line, data.pincode, data.district, data.state]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertEmployee = async (data: any, conn: any) => {
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[data.empCode, data.partnerId, data.companyId, data.branchId, true]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertEmploymentTerms = async (data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'ACTIVE', ?)`,
|
||||||
|
[data.employeeId, data.work_email, data.dateJoining, data.probationDays, data.salaryStructureId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertEmployeeAssignment = async (data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
||||||
|
VALUES (?, ?, ?, ?, 'HIRE', ?, TRUE)`,
|
||||||
|
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.dateJoining]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePartner = async (partnerId: number, data: any, conn: any) => {
|
||||||
|
let query = "UPDATE partners SET ";
|
||||||
|
const values = [];
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
// Only add to query if the field is actually provided and not an empty string
|
||||||
|
if (data.firstName !== undefined && data.firstName !== "") { fields.push("first_name = ?"); values.push(data.firstName); }
|
||||||
|
if (data.lastName !== undefined && data.lastName !== "") { fields.push("last_name = ?"); values.push(data.lastName); }
|
||||||
|
if (data.dob !== undefined && data.dob !== "") { fields.push("dob = ?"); values.push(data.dob); }
|
||||||
|
if (data.gender !== undefined && data.gender !== "") { fields.push("gender = ?"); values.push(data.gender); }
|
||||||
|
if (data.personalEmail !== undefined && data.personalEmail !== "") { fields.push("personal_email = ?"); values.push(data.personalEmail); }
|
||||||
|
if (data.personalPhone !== undefined && data.personalPhone !== "") { fields.push("personal_phone = ?"); values.push(data.personalPhone); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return; // Nothing to update
|
||||||
|
|
||||||
|
query += fields.join(", ");
|
||||||
|
query += " WHERE partner_id = ?";
|
||||||
|
values.push(partnerId);
|
||||||
|
|
||||||
|
await conn.execute(query, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEmploymentTerms = async (employeeId: number, data: any, conn: any) => {
|
||||||
|
let query = "UPDATE employment_terms SET ";
|
||||||
|
const values = [];
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
if (data.work_email !== undefined && data.work_email !== "") { fields.push("work_email = ?"); values.push(data.work_email); }
|
||||||
|
if (data.probationDays !== undefined && data.probationDays !== null) { fields.push("probation_days = ?"); values.push(data.probationDays); }
|
||||||
|
if (data.salaryStructureId !== undefined && data.salaryStructureId !== null) { fields.push("salary_structure_id = ?"); values.push(data.salaryStructureId); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
query += fields.join(", ");
|
||||||
|
query += " WHERE employee_id = ? AND status = 'ACTIVE'";
|
||||||
|
values.push(employeeId);
|
||||||
|
|
||||||
|
await conn.execute(query, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEmployeeAssignment = async (employeeId: number, data: any, conn: any) => {
|
||||||
|
let query = "UPDATE employee_assignments SET ";
|
||||||
|
const values = [];
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
if (data.departmentId !== undefined && data.departmentId !== null) { fields.push("department_id = ?"); values.push(data.departmentId); }
|
||||||
|
if (data.jobId !== undefined && data.jobId !== null) { fields.push("job_id = ?"); values.push(data.jobId); }
|
||||||
|
// reportingToId can be null to clear a manager, so we check if it's explicitly undefined
|
||||||
|
if (data.reportingToId !== undefined) { fields.push("reporting_to_id = ?"); values.push(data.reportingToId); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
query += fields.join(", ");
|
||||||
|
query += " WHERE employee_id = ? AND is_current = TRUE";
|
||||||
|
values.push(employeeId);
|
||||||
|
|
||||||
|
await conn.execute(query, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEmployeeStatus = async (employeeId: number, isActive: boolean, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE employees SET is_active = ? WHERE employee_id = ?`,
|
||||||
|
[isActive, employeeId]
|
||||||
|
);
|
||||||
|
};
|
||||||
31
services/ems/repositories/internal.repository.ts
Normal file
31
services/ems/repositories/internal.repository.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// services/ems/repositories/internal.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const findInternalRoster = async (ids?: number[]) => {
|
||||||
|
let query = `
|
||||||
|
SELECT e.employee_id, e.employee_code, e.is_active, CONCAT_WS(' ', p.first_name, p.last_name) AS full_name,
|
||||||
|
b.branch_id, b.branch_name, c.company_id, c.name AS company_name,
|
||||||
|
d.name AS department_name, d.department_id, j.title AS job_title,
|
||||||
|
CONCAT_WS(' ', mgr_p.first_name, mgr_p.last_name) AS manager_name,
|
||||||
|
ea.reporting_to_id AS manager_id
|
||||||
|
FROM employees e
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN companies c ON e.company_id = c.company_id
|
||||||
|
JOIN branches b ON e.branch_id = b.branch_id
|
||||||
|
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
||||||
|
JOIN departments d ON ea.department_id = d.department_id
|
||||||
|
JOIN job_positions j ON ea.job_id = j.job_id
|
||||||
|
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
|
||||||
|
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
||||||
|
WHERE e.is_active = TRUE`;
|
||||||
|
|
||||||
|
const values: any[] = [];
|
||||||
|
if (ids && ids.length > 0) {
|
||||||
|
const placeholders = ids.map(() => "?").join(",");
|
||||||
|
query += ` AND e.employee_id IN (${placeholders})`;
|
||||||
|
values.push(...ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
189
services/ems/repositories/lookup.repository.ts
Normal file
189
services/ems/repositories/lookup.repository.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// services/ems/repositories/lookup.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMPANIES
|
||||||
|
// ==========================================
|
||||||
|
export const findCompanies = async (filters: any) => {
|
||||||
|
let query = "SELECT company_id, company_code, name, is_active FROM companies WHERE 1=1";
|
||||||
|
const values: any[] = [];
|
||||||
|
if (filters.is_active !== undefined) { query += " AND is_active = ?"; values.push(filters.is_active); }
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertCompany = async (data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO companies (company_code, name, parent_id, is_active) VALUES (?, ?, ?, ?)`,
|
||||||
|
[data.company_code, data.name, data.parent_id, data.is_active]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCompanyById = async (id: number, data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE companies SET name = ?, parent_id = ?, is_active = ? WHERE company_id = ?`,
|
||||||
|
[data.name, data.parent_id, data.is_active, id]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCompanyById = async (id: number) => {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BRANCHES
|
||||||
|
// ==========================================
|
||||||
|
export const findBranches = async (filters: any) => {
|
||||||
|
let query = `SELECT b.branch_id, b.code, b.branch_name, b.is_active, c.company_code, c.name AS company_name
|
||||||
|
FROM branches b JOIN companies c ON b.company_id = c.company_id WHERE 1=1`;
|
||||||
|
const values: any[] = [];
|
||||||
|
if (filters.company_id) { query += " AND b.company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.is_active !== undefined) { query += " AND b.is_active = ?"; values.push(filters.is_active); }
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertBranch = async (data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO branches (company_id, branch_name, code, is_active) VALUES (?, ?, ?, ?)`,
|
||||||
|
[data.company_id, data.branch_name, data.code, data.is_active]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBranchById = async (id: number, data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE branches SET company_id = ?, branch_name = ?, code = ?, is_active = ? WHERE branch_id = ?`,
|
||||||
|
[data.company_id, data.branch_name, data.code, data.is_active, id]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBranchById = async (id: number) => {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DEPARTMENTS
|
||||||
|
// ==========================================
|
||||||
|
export const findDepartments = async (filters: any, sort: { column: string; order: string }) => {
|
||||||
|
let query = `SELECT d.department_id, d.department_code, d.name AS department_name, d.is_active,
|
||||||
|
b.branch_name, c.name AS company_name,
|
||||||
|
(SELECT GROUP_CONCAT(CONCAT(p.first_name, ' ', p.last_name) SEPARATOR ', ')
|
||||||
|
FROM department_managers dm
|
||||||
|
JOIN employees e ON dm.employee_id = e.employee_id
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
WHERE dm.department_id = d.department_id AND dm.is_current = TRUE) AS managers
|
||||||
|
FROM departments d
|
||||||
|
JOIN branches b ON d.branch_id = b.branch_id
|
||||||
|
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
||||||
|
if (filters.is_active !== undefined) { query += " AND d.is_active = ?"; values.push(filters.is_active); }
|
||||||
|
|
||||||
|
query += ` ORDER BY ${sort.column} ${sort.order}`;
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertDepartment = async (data: any, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`INSERT INTO departments (company_id, branch_id, department_code, name, parent_id, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[data.company_id, data.branch_id, data.department_code, data.name, data.parent_id, data.is_active]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)`,
|
||||||
|
[deptId, empId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDepartmentById = async (id: number, data: any, conn: any) => {
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ?, is_active = ? WHERE department_id = ?`,
|
||||||
|
[data.company_id, data.branch_id, data.name, data.parent_id, data.is_active, id]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expireDepartmentManagers = async (deptId: number, conn: any) => {
|
||||||
|
await conn.execute(`UPDATE department_managers SET is_current = FALSE, removed_at = NOW() WHERE department_id = ? AND is_current = TRUE`, [deptId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE is_current = TRUE, removed_at = NULL`,
|
||||||
|
[deptId, empId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDepartmentById = async (id: number) => {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// JOBS
|
||||||
|
// ==========================================
|
||||||
|
export const findJobs = async (filters: any, sort: { column: string; order: string }) => {
|
||||||
|
let query = `SELECT j.job_id, j.job_code, j.title AS job_name, j.is_active,
|
||||||
|
d.name AS department_name, b.branch_name, c.name AS company_name
|
||||||
|
FROM job_positions j
|
||||||
|
JOIN departments d ON j.department_id = d.department_id
|
||||||
|
JOIN branches b ON d.branch_id = b.branch_id
|
||||||
|
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
|
||||||
|
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
||||||
|
if (filters.department_id) { query += " AND j.department_id = ?"; values.push(filters.department_id); }
|
||||||
|
|
||||||
|
query += ` ORDER BY ${sort.column} ${sort.order}`;
|
||||||
|
const [rows] = await pool.query(query, values);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertJob = async (data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO job_positions (department_id, job_code, title, description, is_active) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[data.department_id, data.job_code, data.title, data.description, data.is_active]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateJobById = async (id: number, data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`UPDATE job_positions SET department_id = ?, title = ?, description = ?, is_active = ? WHERE job_id = ?`,
|
||||||
|
[data.department_id, data.title, data.description, data.is_active, id]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteJobById = async (id: number) => {
|
||||||
|
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findDepartmentManagers = async (departmentId: number) => {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name,
|
||||||
|
d.department_id, d.name AS department_name
|
||||||
|
FROM department_managers dm
|
||||||
|
JOIN employees e ON dm.employee_id = e.employee_id
|
||||||
|
JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
JOIN departments d ON dm.department_id = d.department_id
|
||||||
|
WHERE dm.is_current = TRUE AND e.is_active = TRUE AND dm.department_id = ?`,
|
||||||
|
[departmentId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
93
services/ems/repositories/system.repository.ts
Normal file
93
services/ems/repositories/system.repository.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// services/ems/repositories/system.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
export const upsertPartner = async (data: any, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
||||||
|
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
|
||||||
|
[data.firstName, data.lastName, data.email, data.phone]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDepartment = async (name: string, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`INSERT INTO departments (company_id, branch_id, department_code, name)
|
||||||
|
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
||||||
|
[name, name]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertJob = async (deptId: number, title: string, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`INSERT INTO job_positions (department_id, job_code, title)
|
||||||
|
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
||||||
|
[deptId, title, title]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertEmployee = async (empCode: string, partnerId: number, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
||||||
|
VALUES (?, ?, 1, 1, TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
|
||||||
|
[empCode, partnerId]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertEmploymentTerms = async (employeeId: number, email: string, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
||||||
|
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
|
||||||
|
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
|
||||||
|
[employeeId, email]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertAssignment = async (employeeId: number, deptId: number, jobId: number, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
||||||
|
VALUES (?, ?, ?, NULL, 'HIRE', '2026-01-01', TRUE)
|
||||||
|
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id), job_id = VALUES(job_id)`,
|
||||||
|
[employeeId, deptId, jobId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAssignmentManager = async (empCode: string, managerCode: string, conn: any) => {
|
||||||
|
const [result]: any = await conn.execute(
|
||||||
|
`UPDATE employee_assignments ea
|
||||||
|
JOIN employees e ON ea.employee_id = e.employee_id
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
SET ea.reporting_to_id = m.employee_id
|
||||||
|
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
|
||||||
|
[managerCode, empCode]
|
||||||
|
);
|
||||||
|
return result.affectedRows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDepartmentManager = async (deptName: string, managerCode: string, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
|
||||||
|
SELECT d.department_id, m.employee_id, TRUE
|
||||||
|
FROM departments d
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[managerCode, deptName]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDepartmentParent = async (deptName: string, parentName: string, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE departments d
|
||||||
|
JOIN departments p ON p.name = ?
|
||||||
|
SET d.parent_id = p.department_id
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[parentName, deptName]
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,7 +1,5 @@
|
|||||||
// shared/sequence.ts
|
// services/ems/sequence.ts
|
||||||
import { getDbPool } from "./db.ts";
|
import pool from "./db.ts";
|
||||||
|
|
||||||
const pool = getDbPool("hrms_ems");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a sequential code for a given entity.
|
* Generates a sequential code for a given entity.
|
||||||
23
services/ems/services/contract.service.ts
Normal file
23
services/ems/services/contract.service.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// services/ems/services/contract.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import * as ContractRepo from "../repositories/contract.repository.ts";
|
||||||
|
|
||||||
|
export const getEmployeeContracts = async (employeeId: number) => {
|
||||||
|
return await ContractRepo.findContractsByEmployeeId(employeeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createContract = async (data: any) => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
await ContractRepo.expireCurrentAssignment(data.employeeId, connection);
|
||||||
|
const assignmentId = await ContractRepo.insertNewAssignment(data, connection);
|
||||||
|
await connection.commit();
|
||||||
|
return { assignment_id: assignmentId };
|
||||||
|
} catch (error: any) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
19
services/ems/services/dashboard.service.ts
Normal file
19
services/ems/services/dashboard.service.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// services/ems/services/dashboard.service.ts
|
||||||
|
import * as DashboardRepo from "../repositories/dashboard.repository.ts";
|
||||||
|
|
||||||
|
export const getDashboardMetrics = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
||||||
|
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
|
||||||
|
if (params.get('department_id')) filters.department_id = params.get('department_id');
|
||||||
|
|
||||||
|
const [total_companies, total_branches, total_departments, total_jobs, total_employees] = await Promise.all([
|
||||||
|
DashboardRepo.countCompanies(),
|
||||||
|
DashboardRepo.countBranches(filters),
|
||||||
|
DashboardRepo.countDepartments(filters),
|
||||||
|
DashboardRepo.countJobs(filters),
|
||||||
|
DashboardRepo.countEmployees(filters)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { total_companies, total_branches, total_departments, total_jobs, total_employees };
|
||||||
|
};
|
||||||
143
services/ems/services/employee.service.ts
Normal file
143
services/ems/services/employee.service.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
// services/ems/services/employee.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import { generateNextCode } from "../sequence.ts";
|
||||||
|
import * as EmpRepo from "../repositories/employee.repository.ts";
|
||||||
|
|
||||||
|
export const getEmployees = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
||||||
|
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
|
||||||
|
if (params.get('department_id')) filters.department_id = params.get('department_id');
|
||||||
|
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
||||||
|
|
||||||
|
return await EmpRepo.findEmployees(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeById = async (id: number) => {
|
||||||
|
const employee = await EmpRepo.findEmployeeById(id);
|
||||||
|
if (!employee) throw new Error("Employee not found");
|
||||||
|
|
||||||
|
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
|
||||||
|
const addresses = await EmpRepo.findAddressesByPartnerId(partnerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: employee.employee_id,
|
||||||
|
employee_code: employee.employee_code,
|
||||||
|
is_active: employee.is_active,
|
||||||
|
first_name: employee.first_name,
|
||||||
|
last_name: employee.last_name,
|
||||||
|
dob: employee.dob,
|
||||||
|
gender: employee.gender,
|
||||||
|
personal_email: employee.personal_email,
|
||||||
|
personal_phone: employee.personal_phone,
|
||||||
|
|
||||||
|
// FIX: Pass the company and branch fields through to the handler
|
||||||
|
company_name: employee.company_name,
|
||||||
|
company_id: employee.company_id,
|
||||||
|
branch_name: employee.branch_name,
|
||||||
|
branch_id: employee.branch_id,
|
||||||
|
|
||||||
|
contract: {
|
||||||
|
work_email: employee.work_email,
|
||||||
|
date_joining: employee.date_joining,
|
||||||
|
probation_days: employee.probation_days,
|
||||||
|
status: employee.contract_status,
|
||||||
|
salary_structure_id: employee.salary_structure_id,
|
||||||
|
department: employee.department,
|
||||||
|
department_id: employee.department_id,
|
||||||
|
designation: employee.job_name,
|
||||||
|
job_id: employee.job_id,
|
||||||
|
manager: employee.manager_employee_code ? {
|
||||||
|
employee_code: employee.manager_employee_code,
|
||||||
|
first_name: employee.manager_first_name,
|
||||||
|
last_name: employee.manager_last_name
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
addresses: addresses,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeHistory = async (id: number) => {
|
||||||
|
return await EmpRepo.findEmployeeHistory(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmployee = async (data: any) => {
|
||||||
|
const empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const partnerId = await EmpRepo.insertPartner(data, connection);
|
||||||
|
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
|
||||||
|
|
||||||
|
const employeeId = await EmpRepo.insertEmployee({ ...data, empCode, partnerId }, connection);
|
||||||
|
await EmpRepo.insertEmploymentTerms({ ...data, employeeId }, connection);
|
||||||
|
await EmpRepo.insertEmployeeAssignment({ ...data, employeeId }, connection);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
return { employee_id: employeeId, employee_code: empCode };
|
||||||
|
} catch (error: any) {
|
||||||
|
await connection.rollback();
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error("Employee Code, Email, or Phone already exists.");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// updateEmployee as the sole handler for both profile and status:
|
||||||
|
// Change the return type to string so we can pass the message back
|
||||||
|
export const updateEmployee = async (id: number, data: any): Promise<string> => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
|
||||||
|
if (!partnerId) throw new Error("Employee not found");
|
||||||
|
|
||||||
|
// 1. Update Profile Data (only if profile fields are provided)
|
||||||
|
const hasProfileData = data.firstName || data.lastName || data.dob || data.gender || data.personalEmail || data.personalPhone;
|
||||||
|
if (hasProfileData) {
|
||||||
|
await EmpRepo.updatePartner(partnerId, data, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
|
||||||
|
if (data.work_email) await EmpRepo.updateEmploymentTerms(id, data, connection);
|
||||||
|
|
||||||
|
const hasAssignmentData = (data.departmentId !== undefined) || (data.jobId !== undefined) || (data.reportingToId !== undefined);
|
||||||
|
if (hasAssignmentData) {
|
||||||
|
await EmpRepo.updateEmployeeAssignment(id, data, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default message
|
||||||
|
let responseMessage = "Employee profile updated successfully";
|
||||||
|
|
||||||
|
// 2. Update Status (if provided) - SMART IDEMPOTENCY CHECK
|
||||||
|
if (data.isActive !== undefined && data.isActive !== null) {
|
||||||
|
// Fetch current status
|
||||||
|
const [rows]: any = await connection.execute(`SELECT is_active FROM employees WHERE employee_id = ?`, [id]);
|
||||||
|
if (rows.length === 0) throw new Error("Employee not found");
|
||||||
|
|
||||||
|
const currentStatus = !!rows[0].is_active; // Convert tinyint to boolean
|
||||||
|
|
||||||
|
if (currentStatus === data.isActive) {
|
||||||
|
// No DB update needed, just change the response message
|
||||||
|
responseMessage = `Employee is already ${data.isActive ? 'active' : 'inactive'}. No status change made.`;
|
||||||
|
} else {
|
||||||
|
// Status is different, proceed with update
|
||||||
|
await EmpRepo.updateEmployeeStatus(id, data.isActive, connection);
|
||||||
|
responseMessage = `Employee ${data.isActive ? 'activated' : 'deactivated'} successfully.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
return responseMessage; // Return the specific message
|
||||||
|
} catch (error: any) {
|
||||||
|
await connection.rollback();
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error("Email or Phone already belongs to another employee.");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
13
services/ems/services/internal.service.ts
Normal file
13
services/ems/services/internal.service.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// services/ems/services/internal.service.ts
|
||||||
|
import * as InternalRepo from "../repositories/internal.repository.ts";
|
||||||
|
|
||||||
|
export const getRoster = async (idsParam?: string) => {
|
||||||
|
const ids = idsParam ? idsParam.split(",").map(Number) : undefined;
|
||||||
|
return await InternalRepo.findInternalRoster(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getManager = async (employeeId: number) => {
|
||||||
|
const data = await InternalRepo.findInternalManager(employeeId);
|
||||||
|
if (!data) throw new Error("Employee not found");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
265
services/ems/services/lookup.service.ts
Normal file
265
services/ems/services/lookup.service.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
// services/ems/services/lookup.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import { generateNextCode } from "../sequence.ts";
|
||||||
|
import * as LookupRepo from "../repositories/lookup.repository.ts";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// COMPANIES
|
||||||
|
// ==========================================
|
||||||
|
export const getCompanies = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
||||||
|
return await LookupRepo.findCompanies(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCompany = async (data: any) => {
|
||||||
|
const companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.insertCompany({
|
||||||
|
company_code: companyCode,
|
||||||
|
name: data.name,
|
||||||
|
parent_id: data.parentId || null,
|
||||||
|
is_active: data.isActive ?? true
|
||||||
|
});
|
||||||
|
return { company_id: result.insertId, company_code: companyCode };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Company code '${companyCode}' already exists.`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCompany = async (id: number, data: any) => {
|
||||||
|
const result = await LookupRepo.updateCompanyById(id, {
|
||||||
|
name: data.name,
|
||||||
|
parent_id: data.parentId || null,
|
||||||
|
is_active: data.isActive
|
||||||
|
});
|
||||||
|
if (result.affectedRows === 0) throw new Error("Company not found");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCompany = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.deleteCompanyById(id);
|
||||||
|
if (result.affectedRows === 0) throw new Error("Company not found");
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete company. It is currently assigned to one or more employees.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BRANCHES
|
||||||
|
// ==========================================
|
||||||
|
export const getBranches = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
||||||
|
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
||||||
|
return await LookupRepo.findBranches(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBranch = async (data: any) => {
|
||||||
|
let branchCode = data.code;
|
||||||
|
if (!branchCode || branchCode.trim() === "") {
|
||||||
|
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
|
||||||
|
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.insertBranch({
|
||||||
|
company_id: data.companyId,
|
||||||
|
branch_name: data.branchName,
|
||||||
|
code: branchCode,
|
||||||
|
is_active: data.isActive ?? true
|
||||||
|
});
|
||||||
|
return { branch_id: result.insertId, code: branchCode };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Branch code '${branchCode}' already exists.`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBranch = async (id: number, data: any) => {
|
||||||
|
const result = await LookupRepo.updateBranchById(id, {
|
||||||
|
company_id: data.companyId,
|
||||||
|
branch_name: data.branchName,
|
||||||
|
code: data.code,
|
||||||
|
is_active: data.isActive
|
||||||
|
});
|
||||||
|
if (result.affectedRows === 0) throw new Error("Branch not found");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBranch = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.deleteBranchById(id);
|
||||||
|
if (result.affectedRows === 0) throw new Error("Branch not found");
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete branch. It is currently assigned to one or more employees.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DEPARTMENTS
|
||||||
|
// ==========================================
|
||||||
|
const mapDeptSortColumn = (sortBy: string): string => {
|
||||||
|
const validColumns: Record<string, string> = {
|
||||||
|
'department_name': 'd.name', 'department_code': 'd.department_code', 'company_name': 'c.name', 'branch_name': 'b.branch_name'
|
||||||
|
};
|
||||||
|
return validColumns[sortBy] || 'd.name';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDepartments = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
|
||||||
|
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
|
||||||
|
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
||||||
|
|
||||||
|
const sort = {
|
||||||
|
column: mapDeptSortColumn(params.get('sort_by') || 'department_name'),
|
||||||
|
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
|
||||||
|
};
|
||||||
|
|
||||||
|
return await LookupRepo.findDepartments(filters, sort);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDepartment = async (data: any) => {
|
||||||
|
if (!data.companyId || !data.branchId || !data.name) throw new Error("companyId, branchId, and name are required.");
|
||||||
|
|
||||||
|
const deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const departmentId = await LookupRepo.insertDepartment({
|
||||||
|
company_id: data.companyId,
|
||||||
|
branch_id: data.branchId,
|
||||||
|
department_code: deptCode,
|
||||||
|
name: data.name,
|
||||||
|
parent_id: data.parentId ?? null,
|
||||||
|
is_active: data.isActive ?? true
|
||||||
|
}, connection);
|
||||||
|
|
||||||
|
if (data.managerId) {
|
||||||
|
await LookupRepo.insertDepartmentManager(departmentId, data.managerId, connection);
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return { department_id: departmentId, department_code: deptCode };
|
||||||
|
} catch (error: any) {
|
||||||
|
await connection.rollback();
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Department code '${deptCode}' already exists.`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDepartment = async (id: number, data: any) => {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const result = await LookupRepo.updateDepartmentById(id, {
|
||||||
|
company_id: data.companyId ?? null,
|
||||||
|
branch_id: data.branchId ?? null,
|
||||||
|
name: data.name ?? null,
|
||||||
|
parent_id: data.parentId ?? null,
|
||||||
|
is_active: data.isActive
|
||||||
|
}, connection);
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) throw new Error("Department not found");
|
||||||
|
|
||||||
|
if (data.managerId) {
|
||||||
|
await LookupRepo.expireDepartmentManagers(id, connection);
|
||||||
|
await LookupRepo.upsertDepartmentManager(id, data.managerId, connection);
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDepartment = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.deleteDepartmentById(id);
|
||||||
|
if (result.affectedRows === 0) throw new Error("Department not found");
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete department. There are active assignments tied to it.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// JOBS
|
||||||
|
// ==========================================
|
||||||
|
const mapJobSortColumn = (sortBy: string): string => {
|
||||||
|
const validColumns: Record<string, string> = {
|
||||||
|
'job_name': 'j.title', 'job_code': 'j.job_code', 'department_name': 'd.name'
|
||||||
|
};
|
||||||
|
return validColumns[sortBy] || 'j.title';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getJobs = async (params: any) => {
|
||||||
|
const filters: any = {};
|
||||||
|
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
|
||||||
|
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
|
||||||
|
if (params.get('department_id')) filters.department_id = Number(params.get('department_id'));
|
||||||
|
|
||||||
|
const sort = {
|
||||||
|
column: mapJobSortColumn(params.get('sort_by') || 'job_name'),
|
||||||
|
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
|
||||||
|
};
|
||||||
|
|
||||||
|
return await LookupRepo.findJobs(filters, sort);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createJob = async (data: any) => {
|
||||||
|
if (!data.departmentId || !data.title) throw new Error("departmentId and title are required.");
|
||||||
|
|
||||||
|
const jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.insertJob({
|
||||||
|
department_id: data.departmentId,
|
||||||
|
job_code: jobCode,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description ?? null,
|
||||||
|
is_active: data.isActive ?? true
|
||||||
|
});
|
||||||
|
return { job_id: result.insertId, job_code: jobCode };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Job code '${jobCode}' already exists.`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateJob = async (id: number, data: any) => {
|
||||||
|
const result = await LookupRepo.updateJobById(id, {
|
||||||
|
department_id: data.departmentId ?? null,
|
||||||
|
title: data.title ?? null,
|
||||||
|
description: data.description ?? null,
|
||||||
|
is_active: data.isActive
|
||||||
|
});
|
||||||
|
if (result.affectedRows === 0) throw new Error("Job not found");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteJob = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const result = await LookupRepo.deleteJobById(id);
|
||||||
|
if (result.affectedRows === 0) throw new Error("Job not found");
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete job position. It is currently linked to active employee assignments.");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDepartmentManagers = async (departmentId: number) => {
|
||||||
|
return await LookupRepo.findDepartmentManagers(departmentId);
|
||||||
|
};
|
||||||
70
services/ems/services/system.service.ts
Normal file
70
services/ems/services/system.service.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// services/ems/services/system.service.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
import * as SystemRepo from "../repositories/system.repository.ts";
|
||||||
|
|
||||||
|
export const bulkSeedEmployees = async (body: any[]) => {
|
||||||
|
if (!Array.isArray(body) || body.length === 0) throw new Error("Provide an array of employee logs.");
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let insertedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
for (const item of body) {
|
||||||
|
const { emp_code, name, designation, department, email } = item;
|
||||||
|
const nameParts = name.trim().split(" ");
|
||||||
|
const firstName = nameParts[0] || "Employee";
|
||||||
|
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
||||||
|
|
||||||
|
const partnerId = await SystemRepo.upsertPartner({ firstName, lastName, email: `personal.${email}`, phone: `MOCK_${emp_code}` }, connection);
|
||||||
|
const departmentId = await SystemRepo.upsertDepartment(department || "General", connection);
|
||||||
|
const jobId = await SystemRepo.upsertJob(departmentId, designation || "Trainee", connection);
|
||||||
|
const employeeId = await SystemRepo.upsertEmployee(emp_code, partnerId, connection);
|
||||||
|
|
||||||
|
await SystemRepo.upsertEmploymentTerms(employeeId, email, connection);
|
||||||
|
await SystemRepo.upsertAssignment(employeeId, departmentId, jobId, connection);
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return { count: insertedCount };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkMapHierarchy = async (body: any) => {
|
||||||
|
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
|
||||||
|
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) throw new Error("Provide hierarchy mapping arrays.");
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let assignmentsUpdated = 0;
|
||||||
|
let departmentsUpdated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
for (const mapping of employeeHierarchy) {
|
||||||
|
if (mapping.emp_code && mapping.manager_code) {
|
||||||
|
const affected = await SystemRepo.updateAssignmentManager(mapping.emp_code, mapping.manager_code, connection);
|
||||||
|
if (affected > 0) assignmentsUpdated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dept of departmentHierarchy) {
|
||||||
|
if (!dept.department_name) continue;
|
||||||
|
if (dept.manager_code) await SystemRepo.upsertDepartmentManager(dept.department_name, dept.manager_code, connection);
|
||||||
|
if (dept.parent_department_name) await SystemRepo.updateDepartmentParent(dept.department_name, dept.parent_department_name, connection);
|
||||||
|
departmentsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
return { assignmentsUpdated, departmentsProcessed: departmentsUpdated };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
24
services/gateway/authorization/middleware.ts
Normal file
24
services/gateway/authorization/middleware.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// services/gateway/authorization/middleware.ts
|
||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
|
||||||
|
export enum AppRole {
|
||||||
|
DIRECTOR = "DIRECTOR",
|
||||||
|
HR_MANAGER = "HR_MANAGER",
|
||||||
|
MANAGER = "MANAGER",
|
||||||
|
EMPLOYEE = "EMPLOYEE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireRole = (allowedRoles: AppRole[]) => {
|
||||||
|
return async (ctx: Context, next: Next) => {
|
||||||
|
const userRole = ctx.state.user?.role as AppRole;
|
||||||
|
if (!userRole || !allowedRoles.includes(userRole)) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Access Denied: You do not have the required permissions."
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
};
|
||||||
50
services/gateway/clients/index.ts
Normal file
50
services/gateway/clients/index.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// services/gateway/clients/index.ts
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
|
||||||
|
// EMS Clients
|
||||||
|
import { EmployeeServiceClient, LookupServiceClient, ContractServiceClient, DashboardServiceClient as EmsDashboardClient } from "../../../generated/ems.ts";
|
||||||
|
// AMS Clients
|
||||||
|
import { AttendanceServiceClient, RegularizationServiceClient, DashboardServiceClient as AmsDashboardClient } from "../../../generated/ams.ts";
|
||||||
|
// LMS Clients
|
||||||
|
import { AdminServiceClient, EmployeeServiceClient as LmsEmployeeClient, LeaveServiceClient, ManagerServiceClient, ReportsServiceClient } from "../../../generated/lms.ts";
|
||||||
|
import { HRManagerServiceClient } from "../../../generated/lms.ts";
|
||||||
|
|
||||||
|
const creds = grpc.credentials.createInsecure();
|
||||||
|
|
||||||
|
export const clients = {
|
||||||
|
ems: {
|
||||||
|
employee: new EmployeeServiceClient(config.urls.ems, creds),
|
||||||
|
lookup: new LookupServiceClient(config.urls.ems, creds),
|
||||||
|
contract: new ContractServiceClient(config.urls.ems, creds),
|
||||||
|
dashboard: new EmsDashboardClient(config.urls.ems, creds),
|
||||||
|
},
|
||||||
|
ams: {
|
||||||
|
attendance: new AttendanceServiceClient(config.urls.ams, creds),
|
||||||
|
regularization: new RegularizationServiceClient(config.urls.ams, creds),
|
||||||
|
dashboard: new AmsDashboardClient(config.urls.ams, creds),
|
||||||
|
},
|
||||||
|
lms: {
|
||||||
|
admin: new AdminServiceClient(config.urls.lms, creds),
|
||||||
|
employee: new LmsEmployeeClient(config.urls.lms, creds),
|
||||||
|
leave: new LeaveServiceClient(config.urls.lms, creds),
|
||||||
|
manager: new ManagerServiceClient(config.urls.lms, creds),
|
||||||
|
reports: new ReportsServiceClient(config.urls.lms, creds),
|
||||||
|
hrManager: new HRManagerServiceClient(config.urls.lms, creds)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared helper to promisify gRPC calls AND inject internal token metadata
|
||||||
|
export const callGrpc = (client: any, method: string, payload: any) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Create metadata object and inject the internal token
|
||||||
|
const metadata = new grpc.Metadata();
|
||||||
|
metadata.add('x-internal-token', config.internalToken);
|
||||||
|
|
||||||
|
// Pass metadata as the 3rd argument to the gRPC method
|
||||||
|
client[method](payload, metadata, (err: any, response: any) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
22
services/gateway/config.ts
Normal file
22
services/gateway/config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// services/gateway/config.ts
|
||||||
|
|
||||||
|
const requiredEnvVars = [
|
||||||
|
"EMS_URL", "AMS_URL", "LMS_URL", "INTERNAL_SERVICE_TOKEN", "GATEWAY_PORT"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envVar of requiredEnvVars) {
|
||||||
|
if (!Deno.env.get(envVar)) {
|
||||||
|
throw new Error(`[Gateway] Missing required environment variable: ${envVar}. Application cannot start.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(Deno.env.get("GATEWAY_PORT")),
|
||||||
|
mockSso: Deno.env.get("MOCK_SSO") === "true",
|
||||||
|
internalToken: Deno.env.get("INTERNAL_SERVICE_TOKEN")!,
|
||||||
|
urls: {
|
||||||
|
ems: Deno.env.get("EMS_URL")!,
|
||||||
|
ams: Deno.env.get("AMS_URL")!,
|
||||||
|
lms: Deno.env.get("LMS_URL")!,
|
||||||
|
}
|
||||||
|
};
|
||||||
5
services/gateway/deno.json
Normal file
5
services/gateway/deno.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@service/gateway",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"exports": "./main.ts"
|
||||||
|
}
|
||||||
28
services/gateway/identity/middleware.ts
Normal file
28
services/gateway/identity/middleware.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// services/gateway/identity/middleware.ts
|
||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
import { config } from "../config.ts";
|
||||||
|
|
||||||
|
export interface UserContext {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
managedBranches: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const identityMiddleware = async (ctx: Context, next: Next) => {
|
||||||
|
if (config.mockSso) {
|
||||||
|
// MOCK ADAPTER: Reads debug headers
|
||||||
|
ctx.state.user = {
|
||||||
|
id: ctx.request.headers.get("X-Mock-User-Id") || "126",
|
||||||
|
role: ctx.request.headers.get("X-Mock-User-Role") || "EMPLOYEE",
|
||||||
|
managedBranches: ctx.request.headers.get("X-Mock-Branches") || "1"
|
||||||
|
} as UserContext;
|
||||||
|
} else {
|
||||||
|
// PRODUCTION ADAPTER (Keycloak JWT validation goes here)
|
||||||
|
// const jwt = ctx.request.headers.get("Authorization")?.split(" ")[1];
|
||||||
|
// ctx.state.user = await validateKeycloakJWT(jwt);
|
||||||
|
ctx.response.status = 401;
|
||||||
|
ctx.response.body = { error: "Real SSO not implemented yet." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
45
services/gateway/main.ts
Normal file
45
services/gateway/main.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// services/gateway/main.ts
|
||||||
|
import { Application } from "@oak/oak";
|
||||||
|
import { config } from "./config.ts";
|
||||||
|
import { identityMiddleware } from "./identity/middleware.ts";
|
||||||
|
|
||||||
|
// Import modular routes
|
||||||
|
import emsRoutes from "./routes/ems.routes.ts";
|
||||||
|
import amsRoutes from "./routes/ams.routes.ts";
|
||||||
|
import lmsRoutes from "./routes/lms.routes.ts";
|
||||||
|
|
||||||
|
const app = new Application();
|
||||||
|
|
||||||
|
// 1. Global CORS & Error Handling
|
||||||
|
app.use(async (ctx, next) => {
|
||||||
|
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
||||||
|
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||||
|
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Mock-User-Id, X-Mock-User-Role, X-Mock-Branches");
|
||||||
|
if (ctx.request.method === "OPTIONS") {
|
||||||
|
ctx.response.status = 204;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Gateway Error]", err);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, message: "Internal Gateway Error" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. AuthN Layer (Identity Resolution)
|
||||||
|
app.use(identityMiddleware);
|
||||||
|
|
||||||
|
// 3. AuthZ & Routing Layer (Mount module routers)
|
||||||
|
app.use(emsRoutes.routes());
|
||||||
|
app.use(emsRoutes.allowedMethods());
|
||||||
|
|
||||||
|
app.use(amsRoutes.routes());
|
||||||
|
app.use(amsRoutes.allowedMethods());
|
||||||
|
|
||||||
|
app.use(lmsRoutes.routes());
|
||||||
|
app.use(lmsRoutes.allowedMethods());
|
||||||
|
|
||||||
|
console.log(`🚀 API Gateway running on http://localhost:${config.port}`);
|
||||||
|
await app.listen({ port: config.port });
|
||||||
232
services/gateway/routes/ams.routes.ts
Normal file
232
services/gateway/routes/ams.routes.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// services/gateway/routes/ams.routes.ts
|
||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { clients, callGrpc } from "../clients/index.ts";
|
||||||
|
import { AppRole, requireRole } from "../authorization/middleware.ts";
|
||||||
|
import { getJsonBody } from "../utils/http.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ATTENDANCE ROUTES (AMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ams/attendance/logs", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "getRawLogs", {});
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ams/attendance/process-daily", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
workDate: body.work_date || undefined,
|
||||||
|
startDate: body.start_date || undefined,
|
||||||
|
endDate: body.end_date || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "processDailyAttendance", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/my-summary", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
employeeId: Number(p.get("employee_id")) || 0,
|
||||||
|
startDate: p.get("start_date") || "",
|
||||||
|
endDate: p.get("end_date") || ""
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "getEmployeeSummary", req);
|
||||||
|
ctx.response.body = { success: res.success, metrics: res.metrics, history: res.history };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/daily-report", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
date: p.get("date") || "",
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
|
||||||
|
designation: p.get("designation") || undefined,
|
||||||
|
employeeName: p.get("employee_name") || undefined,
|
||||||
|
employeeCode: p.get("employee_code") || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "getDailyReport", req);
|
||||||
|
ctx.response.body = { success: res.success, date: res.date, summary: res.summary, roster: res.roster };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/admin-report", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
fromDate: p.get("from_date") || "",
|
||||||
|
toDate: p.get("to_date") || "",
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
designation: p.get("designation") || undefined,
|
||||||
|
employeeName: p.get("employee_name") || undefined,
|
||||||
|
employeeCode: p.get("employee_code") || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "getAdminRangeReport", req);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
date_range: { from: res.fromDate, to: res.toDate },
|
||||||
|
global_summary: res.globalSummary,
|
||||||
|
report: res.report
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/employee-range-report", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
employeeId: Number(p.get("employee_id")) || 0,
|
||||||
|
fromDate: p.get("from_date") || "",
|
||||||
|
toDate: p.get("to_date") || ""
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "getSingleEmployeeRangeReport", req);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
employee: res.employee,
|
||||||
|
range: { from: res.fromDate, to: res.toDate },
|
||||||
|
total_days: res.totalDays,
|
||||||
|
history: res.history
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REGULARIZATION ROUTES (AMS)
|
||||||
|
// ==========================================
|
||||||
|
router.post("/api/ams/attendance/regularize", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
attendanceId: body.attendance_id ? Number(body.attendance_id) : undefined,
|
||||||
|
employeeId: Number(body.employee_id) || 0,
|
||||||
|
regularizationType: body.regularization_type || "",
|
||||||
|
targetDate: body.target_date || "",
|
||||||
|
requestedCheckIn: body.requested_check_in || undefined,
|
||||||
|
requestedCheckOut: body.requested_check_out || undefined,
|
||||||
|
reason: body.reason || ""
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.regularization, "createRegularizationRequest", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/regularize/pending", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const reqPayload = {
|
||||||
|
userRole: ctx.state.user.role,
|
||||||
|
managedBranchIds: ctx.state.user.managedBranches.split(",").map(Number)
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.regularization, "getPendingRegularizations", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ams/attendance/regularize/review", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
regularizationId: Number(body.regularization_id) || 0,
|
||||||
|
action: body.action || "",
|
||||||
|
reviewerId: Number(ctx.state.user.id),
|
||||||
|
userRole: ctx.state.user.role,
|
||||||
|
managedBranchIds: ctx.state.user.managedBranches.split(",").map(Number)
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.regularization, "reviewRegularizationRequest", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD ROUTES (AMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ams/attendance/dashboard/metrics", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
date: p.get("date") || "",
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.dashboard, "getDashboardMetrics", req);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
data: {
|
||||||
|
total_employees: res.totalEmployees,
|
||||||
|
total_checked_in: res.totalCheckedIn,
|
||||||
|
total_not_checked_in: res.totalNotCheckedIn
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/regularize/history", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ams.regularization, "getAllRegularizations", {});
|
||||||
|
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/daily-report/export", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
date: p.get("date") || "",
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
|
||||||
|
designation: p.get("designation") || undefined,
|
||||||
|
employeeName: p.get("employee_name") || undefined,
|
||||||
|
employeeCode: p.get("employee_code") || undefined,
|
||||||
|
fileType: p.get("file_type") || "excel" // NEW
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "exportDailyReport", req);
|
||||||
|
|
||||||
|
ctx.response.headers.set('Content-Disposition', `attachment; filename="${res.fileName}"`);
|
||||||
|
ctx.response.headers.set('Content-Type', 'application/octet-stream');
|
||||||
|
ctx.response.body = res.fileContent;
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ams/attendance/admin-report/export", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = {
|
||||||
|
fromDate: p.get("from_date") || "",
|
||||||
|
toDate: p.get("to_date") || "",
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined,
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
designation: p.get("designation") || undefined,
|
||||||
|
employeeName: p.get("employee_name") || undefined,
|
||||||
|
employeeCode: p.get("employee_code") || undefined,
|
||||||
|
fileType: p.get("file_type") || "excel" // NEW
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ams.attendance, "exportAdminRangeReport", req);
|
||||||
|
|
||||||
|
ctx.response.headers.set('Content-Disposition', `attachment; filename="${res.fileName}"`);
|
||||||
|
ctx.response.headers.set('Content-Type', 'application/octet-stream');
|
||||||
|
ctx.response.body = res.fileContent;
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
350
services/gateway/routes/ems.routes.ts
Normal file
350
services/gateway/routes/ems.routes.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
// services/gateway/routes/ems.routes.ts
|
||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { clients, callGrpc } from "../clients/index.ts";
|
||||||
|
import { AppRole, requireRole } from "../authorization/middleware.ts";
|
||||||
|
import { getJsonBody } from "../utils/http.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EMPLOYEE ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/employees", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const req = { companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined, isActive: p.get("is_active") ? p.get("is_active") === 'true' : undefined };
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "getEmployees", req);
|
||||||
|
ctx.response.body = { success: res.success, count: res.count, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ems/employees/:id", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "getEmployeeById", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/ems/employees/:id/history", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "getEmployeeHistory", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, history: res.history };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/employees", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
firstName: body.firstName || "", lastName: body.lastName || "", dob: body.dob || "", gender: body.gender || "",
|
||||||
|
personalEmail: body.personalEmail || "", personalPhone: body.personalPhone || "",
|
||||||
|
address: body.address || {},
|
||||||
|
companyId: Number(body.companyId) || 0, branchId: Number(body.branchId) || 0, departmentId: Number(body.departmentId) || 0, jobId: Number(body.jobId) || 0,
|
||||||
|
workEmail: body.workEmail || body.work_email || "", dateJoining: body.dateJoining || "",
|
||||||
|
probationDays: Number(body.probationDays) || 0, salaryStructureId: Number(body.salaryStructureId) || 0,
|
||||||
|
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "createEmployee", reqPayload);
|
||||||
|
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, employee_id: res.employeeId, employee_code: res.employeeCode };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/ems/employees/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
data: {
|
||||||
|
firstName: body.firstName || "", lastName: body.lastName || "", dob: body.dob || "", gender: body.gender || "",
|
||||||
|
personalEmail: body.personalEmail || "", personalPhone: body.personalPhone || "",
|
||||||
|
address: body.address || {},
|
||||||
|
companyId: Number(body.companyId) || 0, branchId: Number(body.branchId) || 0, departmentId: Number(body.departmentId) || 0, jobId: Number(body.jobId) || 0,
|
||||||
|
workEmail: body.workEmail || body.work_email || "", dateJoining: body.dateJoining || "",
|
||||||
|
probationDays: Number(body.probationDays) || 0, salaryStructureId: Number(body.salaryStructureId) || 0,
|
||||||
|
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "updateEmployee", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIX: Replaced DELETE with a dedicated PATCH endpoint for status management
|
||||||
|
router.patch("/api/ems/employees/:id/status", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
data: {
|
||||||
|
isActive: body.isActive !== undefined ? !!body.isActive : undefined,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.employee, "updateEmployee", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
// ==========================================
|
||||||
|
// COMPANY ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/companies", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "getCompanies", {});
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/companies", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
name: body.name || "",
|
||||||
|
parentId: body.parentId ? Number(body.parentId) : undefined,
|
||||||
|
isActive: !!body.isActive,
|
||||||
|
companyCode: body.companyCode || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "createCompany", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message, company_id: res.id, company_code: res.code };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/ems/companies/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
companyData: {
|
||||||
|
name: body.name || "",
|
||||||
|
parentId: body.parentId ? Number(body.parentId) : undefined,
|
||||||
|
isActive: !!body.isActive,
|
||||||
|
companyCode: body.companyCode || undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "updateCompany", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/api/ems/companies/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "deleteCompany", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BRANCH ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/branches", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "getBranches", {});
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/branches", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
companyId: Number(body.companyId) || 0,
|
||||||
|
branchName: body.branchName || "",
|
||||||
|
code: body.code || undefined,
|
||||||
|
isActive: !!body.isActive
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "createBranch", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message, branch_id: res.id, code: res.code };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/ems/branches/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
branchData: {
|
||||||
|
companyId: Number(body.companyId) || 0,
|
||||||
|
branchName: body.branchName || "",
|
||||||
|
code: body.code || "",
|
||||||
|
isActive: !!body.isActive
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "updateBranch", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/api/ems/branches/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "deleteBranch", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DEPARTMENT ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/departments", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "getDepartments", {});
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/departments", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
companyId: Number(body.companyId) || 0,
|
||||||
|
branchId: Number(body.branchId) || 0,
|
||||||
|
name: body.name || "",
|
||||||
|
parentId: body.parentId ? Number(body.parentId) : undefined,
|
||||||
|
managerId: body.managerId ? Number(body.managerId) : undefined,
|
||||||
|
isActive: !!body.isActive,
|
||||||
|
departmentCode: body.departmentCode || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "createDepartment", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message, department_id: res.id, department_code: res.code };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/ems/departments/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
departmentData: {
|
||||||
|
companyId: Number(body.companyId) || 0,
|
||||||
|
branchId: Number(body.branchId) || 0,
|
||||||
|
name: body.name || "",
|
||||||
|
parentId: body.parentId ? Number(body.parentId) : undefined,
|
||||||
|
managerId: body.managerId ? Number(body.managerId) : undefined,
|
||||||
|
isActive: !!body.isActive
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "updateDepartment", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/api/ems/departments/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "deleteDepartment", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// JOB ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/jobs", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "getJobs", {});
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/jobs", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
departmentId: Number(body.departmentId) || 0,
|
||||||
|
title: body.title || "",
|
||||||
|
description: body.description || "",
|
||||||
|
isActive: !!body.isActive,
|
||||||
|
jobCode: body.jobCode || undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "createJob", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message, job_id: res.id, job_code: res.code };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/ems/jobs/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
id: Number(ctx.params.id),
|
||||||
|
jobData: {
|
||||||
|
departmentId: Number(body.departmentId) || 0,
|
||||||
|
title: body.title || "",
|
||||||
|
description: body.description || "",
|
||||||
|
isActive: !!body.isActive
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "updateJob", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/api/ems/jobs/:id", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "deleteJob", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CONTRACT ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/employees/:id/contracts", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.ems.contract, "getEmployeeContracts", { id: Number(ctx.params.id) });
|
||||||
|
ctx.response.body = { success: res.success, count: res.count, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/ems/contracts", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(body.employeeId) || 0,
|
||||||
|
departmentId: Number(body.departmentId) || 0,
|
||||||
|
jobId: Number(body.jobId) || 0,
|
||||||
|
reportingToId: body.reportingToId ? Number(body.reportingToId) : undefined,
|
||||||
|
changeReason: body.changeReason || "TRANSFER"
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.contract, "createContract", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: res.success, message: res.message, assignment_id: res.assignmentId };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DASHBOARD ROUTES (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/dashboard/metrics", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const reqPayload = {
|
||||||
|
companyId: p.get("company_id") ? Number(p.get("company_id")) : undefined,
|
||||||
|
branchId: p.get("branch_id") ? Number(p.get("branch_id")) : undefined,
|
||||||
|
departmentId: p.get("department_id") ? Number(p.get("department_id")) : undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.ems.dashboard, "getDashboardMetrics", reqPayload);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
data: {
|
||||||
|
total_companies: res.totalCompanies,
|
||||||
|
total_branches: res.totalBranches,
|
||||||
|
total_departments: res.totalDepartments,
|
||||||
|
total_jobs: res.totalJobs,
|
||||||
|
total_employees: res.totalEmployees
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MANAGERS ROUTE (EMS)
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/ems/managers", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const departmentId = Number(ctx.request.url.searchParams.get("department_id")) || 0;
|
||||||
|
const res: any = await callGrpc(clients.ems.lookup, "getDepartmentManagers", { departmentId });
|
||||||
|
ctx.response.body = { success: res.success, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
231
services/gateway/routes/lms.routes.ts
Normal file
231
services/gateway/routes/lms.routes.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
// services/gateway/routes/lms.routes.ts
|
||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { clients, callGrpc } from "../clients/index.ts";
|
||||||
|
import { AppRole, requireRole } from "../authorization/middleware.ts";
|
||||||
|
import { getJsonBody } from "../utils/http.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LMS ADMIN CONFIG ROUTES
|
||||||
|
// ==========================================
|
||||||
|
router.post("/api/lms/config/types", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
companyId: Number(body.company_id) || 0,
|
||||||
|
name: body.name || "",
|
||||||
|
requiresAllocation: !!body.requires_allocation,
|
||||||
|
carryOverAllowed: !!body.carry_over_allowed,
|
||||||
|
maxCarryOverDays: Number(body.max_carry_over_days) || 0
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.admin, "createLeaveType", reqPayload);
|
||||||
|
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, leave_type_id: res.id };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/lms/config/types", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const companyId = Number(ctx.request.url.searchParams.get("company_id")) || 0;
|
||||||
|
const res: any = await callGrpc(clients.lms.admin, "getLeaveTypes", { companyId });
|
||||||
|
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/lms/config/rules", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
leaveTypeId: Number(body.leave_type_id) || 0,
|
||||||
|
companyId: Number(body.company_id) || 0,
|
||||||
|
branchId: body.branch_id ? Number(body.branch_id) : undefined,
|
||||||
|
calendarYear: Number(body.calendar_year) || 0,
|
||||||
|
yearlyAllowance: Number(body.yearly_allowance) || 0,
|
||||||
|
maxDaysPerMonth: body.max_days_per_month ? Number(body.max_days_per_month) : undefined,
|
||||||
|
maxConsecutiveDays: body.max_consecutive_days ? Number(body.max_consecutive_days) : undefined,
|
||||||
|
applySandwichPolicy: !!body.apply_sandwich_policy
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.admin, "createPolicyRule", reqPayload);
|
||||||
|
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message, rule_id: res.id };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/api/lms/config/holidays", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
holidays: body.map((h: any) => ({
|
||||||
|
companyId: Number(h.company_id) || 0,
|
||||||
|
branchId: h.branch_id ? Number(h.branch_id) : undefined,
|
||||||
|
calendarYear: Number(h.calendar_year) || 0,
|
||||||
|
holidayDate: h.holiday_date || "",
|
||||||
|
holidayType: h.holiday_type || "MANDATORY",
|
||||||
|
holidayName: h.holiday_name || ""
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.admin, "createCompanyHoliday", reqPayload);
|
||||||
|
ctx.response.status = 201; ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/api/lms/config/work-settings", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
companyId: Number(body.company_id) || 0,
|
||||||
|
branchId: body.branch_id ? Number(body.branch_id) : undefined,
|
||||||
|
weeklyOffDays: JSON.stringify(body.weekly_off_days || []),
|
||||||
|
effectiveFrom: body.effective_from || ""
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.admin, "updateWorkSettings", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LMS EMPLOYEE ACTION ROUTES
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/lms/leaves/balances", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(ctx.state.user.id),
|
||||||
|
role: ctx.state.user.role,
|
||||||
|
targetEmployeeId: p.get("employee_id") ? Number(p.get("employee_id")) : undefined,
|
||||||
|
year: p.get("year") ? Number(p.get("year")) : undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.employee, "getLeaveBalances", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, balances: res.balances };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/lms/leaves/history", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(ctx.state.user.id),
|
||||||
|
role: ctx.state.user.role,
|
||||||
|
targetEmployeeId: p.get("employee_id") ? Number(p.get("employee_id")) : undefined,
|
||||||
|
year: p.get("year") ? Number(p.get("year")) : undefined
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.employee, "getLeaveHistory", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, count: res.history.length, history: res.history };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/lms/holidays/valid-optional", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(ctx.state.user.id),
|
||||||
|
role: ctx.state.user.role,
|
||||||
|
managedBranches: ctx.state.user.managedBranches
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.employee, "getValidOptionalHolidays", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, count: res.holidays.length, holidays: res.holidays };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inside /api/lms/leaves/apply
|
||||||
|
router.post("/api/lms/leaves/apply", async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(body.employee_id) || 0,
|
||||||
|
leaveTypeId: Number(body.leave_type_id) || 0,
|
||||||
|
companyId: Number(body.company_id) || 0,
|
||||||
|
branchId: Number(body.branch_id) || 0,
|
||||||
|
dateFrom: body.date_from || "",
|
||||||
|
dateTo: body.date_to || "",
|
||||||
|
isHalfDay: !!body.is_half_day,
|
||||||
|
reason: body.reason || "",
|
||||||
|
applicantRole: ctx.state.user.role // <--- ADD THIS LINE
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.leave, "applyForLeave", reqPayload);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
message: res.message,
|
||||||
|
total_requested: res.totalRequested,
|
||||||
|
paid_days: res.paidDays,
|
||||||
|
lop_days: res.lopDays,
|
||||||
|
application_ids: res.applicationIds
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LMS MANAGER ACTION ROUTES
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/lms/manager/pending", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const reqPayload = {
|
||||||
|
employeeId: Number(ctx.state.user.id),
|
||||||
|
role: ctx.state.user.role,
|
||||||
|
managedBranches: ctx.state.user.managedBranches
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.manager, "getPendingManagerLeaves", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inside /api/lms/manager/leaves/:applicationId/approve
|
||||||
|
router.post("/api/lms/manager/leaves/:applicationId/approve", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const reqPayload = {
|
||||||
|
managerId: Number(ctx.state.user.id),
|
||||||
|
applicationId: Number(ctx.params.applicationId),
|
||||||
|
userRole: ctx.state.user.role // INJECT ROLE
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.manager, "approveLeaveManager", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inside /api/lms/manager/leaves/:applicationId/reject
|
||||||
|
router.post("/api/lms/manager/leaves/:applicationId/reject", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const body = await getJsonBody(ctx);
|
||||||
|
const reqPayload = {
|
||||||
|
managerId: Number(ctx.state.user.id),
|
||||||
|
applicationId: Number(ctx.params.applicationId),
|
||||||
|
reason: body.reason || "",
|
||||||
|
userRole: ctx.state.user.role // INJECT ROLE
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.manager, "rejectLeaveManager", reqPayload);
|
||||||
|
ctx.response.body = { success: res.success, message: res.message };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LMS REPORTS ROUTES
|
||||||
|
// ==========================================
|
||||||
|
router.get("/api/lms/admin/reports/ob-cb", requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const p = ctx.request.url.searchParams;
|
||||||
|
const reqPayload = {
|
||||||
|
month: Number(p.get("month")) || 0,
|
||||||
|
year: Number(p.get("year")) || 0,
|
||||||
|
companyId: Number(p.get("company_id")) || 0
|
||||||
|
};
|
||||||
|
const res: any = await callGrpc(clients.lms.reports, "getLedgerReport", reqPayload);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: res.success,
|
||||||
|
report_month: res.reportMonth,
|
||||||
|
report_year: res.reportYear,
|
||||||
|
count: res.data.length,
|
||||||
|
data: res.data
|
||||||
|
};
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add this route for HR to see all applications
|
||||||
|
router.get("/api/lms/hr/applications", requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await callGrpc(clients.lms.hrManager, "getAllLeaveApplications", {});
|
||||||
|
ctx.response.body = { success: res.success, count: res.data.length, data: res.data };
|
||||||
|
} catch (e: any) { ctx.response.status = 500; ctx.response.body = { error: e.message }; }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
8
services/gateway/utils/http.ts
Normal file
8
services/gateway/utils/http.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// services/gateway/utils/http.ts
|
||||||
|
export const getJsonBody = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
return await ctx.request.body.json();
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
15
services/lms/db.ts
Normal file
15
services/lms/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// services/lms/db.ts
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
||||||
|
port: Number(Deno.env.get("LMS_DB_PORT")) || 3308,
|
||||||
|
user: Deno.env.get("DB_USER") || "admin",
|
||||||
|
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||||
|
database: "hrms_lms",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default pool;
|
||||||
42
services/lms/handlers/admin.handler.ts
Normal file
42
services/lms/handlers/admin.handler.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// services/lms/handlers/admin.handler.ts
|
||||||
|
import * as AdminService from "../services/admin.service.ts";
|
||||||
|
|
||||||
|
export const AdminServiceImplementation = {
|
||||||
|
async createLeaveType(req: any) {
|
||||||
|
try {
|
||||||
|
const id = await AdminService.createLeaveType(req);
|
||||||
|
return { success: true, message: "Leave type created", id };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message, id: 0 }; }
|
||||||
|
},
|
||||||
|
async getLeaveTypes(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await AdminService.getLeaveTypes(Number(req.companyId));
|
||||||
|
const mappedData = data.map((lt: any) => ({
|
||||||
|
leaveTypeId: Number(lt.leave_type_id) || 0,
|
||||||
|
name: lt.name || "",
|
||||||
|
requiresAllocation: !!lt.requires_allocation,
|
||||||
|
carryOverAllowed: !!lt.carry_over_allowed,
|
||||||
|
maxCarryOverDays: Number(lt.max_carry_over_days) || 0
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, data: [] }; }
|
||||||
|
},
|
||||||
|
async createPolicyRule(req: any) {
|
||||||
|
try {
|
||||||
|
const id = await AdminService.createPolicyRule(req);
|
||||||
|
return { success: true, message: "Policy rule created", id };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message, id: 0 }; }
|
||||||
|
},
|
||||||
|
async createCompanyHoliday(req: any) {
|
||||||
|
try {
|
||||||
|
await AdminService.createCompanyHoliday(req.holidays || []);
|
||||||
|
return { success: true, message: "Holidays inserted successfully." };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async updateWorkSettings(req: any) {
|
||||||
|
try {
|
||||||
|
await AdminService.updateWorkSettings({ ...req, weekly_off_days: JSON.parse(req.weeklyOffDays || "[]") });
|
||||||
|
return { success: true, message: "Work settings updated successfully." };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
}
|
||||||
|
};
|
||||||
49
services/lms/handlers/employee.handler.ts
Normal file
49
services/lms/handlers/employee.handler.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// services/lms/handlers/employee.handler.ts
|
||||||
|
import * as EmployeeService from "../services/employee.service.ts";
|
||||||
|
|
||||||
|
export const EmployeeServiceImplementation = {
|
||||||
|
async getLeaveBalances(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.targetEmployeeId || req.employeeId, role: req.role };
|
||||||
|
const params = { get: (k: string) => k === 'year' ? String(req.year) : null };
|
||||||
|
const data = await EmployeeService.getLeaveBalances(user, params);
|
||||||
|
const mappedData = data.map((b: any) => ({
|
||||||
|
leaveTypeId: Number(b.leave_type_id) || 0,
|
||||||
|
leaveTypeName: b.leave_type_name || "",
|
||||||
|
grantedDays: Number(b.granted_days) || 0,
|
||||||
|
usedDays: Number(b.used_days) || 0,
|
||||||
|
availableBalance: Number(b.available_balance) || 0
|
||||||
|
}));
|
||||||
|
return { success: true, balances: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, balances: [] }; }
|
||||||
|
},
|
||||||
|
async getLeaveHistory(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.targetEmployeeId || req.employeeId, role: req.role };
|
||||||
|
const params = { get: (k: string) => k === 'year' ? String(req.year) : null };
|
||||||
|
const data = await EmployeeService.getLeaveHistory(user, params);
|
||||||
|
const mappedData = data.map((h: any) => ({
|
||||||
|
applicationId: Number(h.application_id) || 0,
|
||||||
|
leaveTypeName: h.leave_type_name || "",
|
||||||
|
dateFrom: h.date_from || "",
|
||||||
|
dateTo: h.date_to || "",
|
||||||
|
numberOfDays: Number(h.number_of_days) || 0,
|
||||||
|
status: h.status || "",
|
||||||
|
reason: h.reason || ""
|
||||||
|
}));
|
||||||
|
return { success: true, history: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, history: [] }; }
|
||||||
|
},
|
||||||
|
async getValidOptionalHolidays(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.employeeId, role: req.role, company_id: 1, branch_id: 1 }; // Mock company/branch for now
|
||||||
|
const data = await EmployeeService.getValidOptionalHolidays(user);
|
||||||
|
const mappedData = data.map((h: any) => ({
|
||||||
|
holidayId: Number(h.holiday_id) || 0,
|
||||||
|
holidayName: h.holiday_name || "",
|
||||||
|
holidayDate: h.holiday_date || ""
|
||||||
|
}));
|
||||||
|
return { success: true, holidays: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, holidays: [] }; }
|
||||||
|
}
|
||||||
|
};
|
||||||
28
services/lms/handlers/hr_manager.handler.ts
Normal file
28
services/lms/handlers/hr_manager.handler.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import * as HRManagerService from "../services/hr_manager.service.ts";
|
||||||
|
|
||||||
|
export const HRManagerServiceImplementation = {
|
||||||
|
async getAllLeaveApplications(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await HRManagerService.getAllApplications();
|
||||||
|
const mappedData = data.map((p: any) => ({
|
||||||
|
applicationId: Number(p.application_id) || 0,
|
||||||
|
employeeId: Number(p.employee_id) || 0,
|
||||||
|
leaveType: p.leave_type || "",
|
||||||
|
dateFrom: p.date_from || "",
|
||||||
|
dateTo: p.date_to || "",
|
||||||
|
numberOfDays: Number(p.number_of_days) || 0,
|
||||||
|
status: p.status || "",
|
||||||
|
reason: p.reason || "", // Ensure reason is mapped
|
||||||
|
employeeCode: p.employee_code || "",
|
||||||
|
firstName: p.first_name || "",
|
||||||
|
lastName: p.last_name || "",
|
||||||
|
departmentName: p.department_name || "",
|
||||||
|
jobTitle: p.job_title || "",
|
||||||
|
managerName: p.manager_name || "N/A" // Ensure managerName is mapped
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
32
services/lms/handlers/leave.handler.ts
Normal file
32
services/lms/handlers/leave.handler.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// services/lms/handlers/leave.handler.ts
|
||||||
|
import * as LeaveService from "../services/leave.service.ts";
|
||||||
|
|
||||||
|
export const LeaveServiceImplementation = {
|
||||||
|
async applyForLeave(req: any) {
|
||||||
|
try {
|
||||||
|
const dbPayload = {
|
||||||
|
employee_id: req.employeeId,
|
||||||
|
leave_type_id: req.leaveTypeId,
|
||||||
|
company_id: req.companyId,
|
||||||
|
branch_id: req.branchId,
|
||||||
|
date_from: req.dateFrom,
|
||||||
|
date_to: req.dateTo,
|
||||||
|
is_half_day: req.isHalfDay,
|
||||||
|
reason: req.reason,
|
||||||
|
applicant_role: req.applicantRole // NEW
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await LeaveService.applyForLeave(dbPayload);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Leave application submitted successfully.",
|
||||||
|
totalRequested: Number(result.total_requested) || 0,
|
||||||
|
paidDays: Number(result.paid_days) || 0,
|
||||||
|
lopDays: Number(result.lop_days) || 0,
|
||||||
|
applicationIds: result.application_ids || []
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, message: e.message, totalRequested: 0, paidDays: 0, lopDays: 0, applicationIds: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
40
services/lms/handlers/manager.handler.ts
Normal file
40
services/lms/handlers/manager.handler.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// services/lms/handlers/manager.handler.ts
|
||||||
|
import * as ManagerService from "../services/manager.service.ts";
|
||||||
|
|
||||||
|
export const ManagerServiceImplementation = {
|
||||||
|
async getPendingManagerLeaves(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.employeeId, role: req.role };
|
||||||
|
const data = await ManagerService.getPendingApprovals(user);
|
||||||
|
const mappedData = data.map((p: any) => ({
|
||||||
|
applicationId: Number(p.application_id) || 0,
|
||||||
|
employeeId: Number(p.employee_id) || 0,
|
||||||
|
leaveTypeId: Number(p.leave_type_id) || 0,
|
||||||
|
leaveType: p.leave_type || "",
|
||||||
|
dateFrom: p.date_from || "",
|
||||||
|
dateTo: p.date_to || "",
|
||||||
|
numberOfDays: Number(p.number_of_days) || 0,
|
||||||
|
reason: p.reason || "",
|
||||||
|
status: p.status || "",
|
||||||
|
employeeCode: p.employee_code || "",
|
||||||
|
firstName: p.first_name || "",
|
||||||
|
lastName: p.last_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, data: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, data: [] }; }
|
||||||
|
},
|
||||||
|
async approveLeaveManager(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.managerId, role: req.userRole }; // Pass userRole
|
||||||
|
await ManagerService.approveLeave(user, Number(req.applicationId));
|
||||||
|
return { success: true, message: "Leave approved successfully." };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
},
|
||||||
|
async rejectLeaveManager(req: any) {
|
||||||
|
try {
|
||||||
|
const user = { employee_id: req.managerId, role: req.userRole }; // Pass userRole
|
||||||
|
await ManagerService.rejectLeave(user, Number(req.applicationId));
|
||||||
|
return { success: true, message: "Leave rejected successfully." };
|
||||||
|
} catch (e: any) { return { success: false, message: e.message }; }
|
||||||
|
}
|
||||||
|
};
|
||||||
21
services/lms/handlers/reports.handler.ts
Normal file
21
services/lms/handlers/reports.handler.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// services/lms/handlers/reports.handler.ts
|
||||||
|
import * as ReportsService from "../services/reports.service.ts";
|
||||||
|
|
||||||
|
export const ReportsServiceImplementation = {
|
||||||
|
async getLedgerReport(req: any) {
|
||||||
|
try {
|
||||||
|
const data = await ReportsService.getLedgerReport(Number(req.month), Number(req.year), Number(req.companyId));
|
||||||
|
const mappedData = data.map((l: any) => ({
|
||||||
|
employeeId: Number(l.employee_id) || 0,
|
||||||
|
leaveType: l.leave_type || "",
|
||||||
|
totalGranted: Number(l.total_granted) || 0,
|
||||||
|
totalUsed: Number(l.total_used) || 0,
|
||||||
|
openingBalance: Number(l.opening_balance) || 0,
|
||||||
|
closingBalance: Number(l.closing_balance) || 0,
|
||||||
|
employeeCode: l.employee_code || "",
|
||||||
|
employeeName: l.employee_name || ""
|
||||||
|
}));
|
||||||
|
return { success: true, reportMonth: Number(req.month), reportYear: Number(req.year), data: mappedData };
|
||||||
|
} catch (e: any) { return { success: false, reportMonth: 0, reportYear: 0, data: [] }; }
|
||||||
|
}
|
||||||
|
};
|
||||||
39
services/lms/main.ts
Normal file
39
services/lms/main.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// services/lms/main.ts
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import { AdminServiceService, EmployeeServiceService, LeaveServiceService, ManagerServiceService, ReportsServiceService, HRManagerServiceService } from "../../generated/lms.ts";
|
||||||
|
import { AdminServiceImplementation } from "./handlers/admin.handler.ts";
|
||||||
|
import { EmployeeServiceImplementation } from "./handlers/employee.handler.ts";
|
||||||
|
import { LeaveServiceImplementation } from "./handlers/leave.handler.ts";
|
||||||
|
import { ManagerServiceImplementation } from "./handlers/manager.handler.ts";
|
||||||
|
import { ReportsServiceImplementation } from "./handlers/reports.handler.ts";
|
||||||
|
import { HRManagerServiceImplementation } from "./handlers/hr_manager.handler.ts";
|
||||||
|
|
||||||
|
const PORT = Number(Deno.env.get("LMS_PORT")) || 8003;
|
||||||
|
|
||||||
|
const wrap = (impl: any) => {
|
||||||
|
const wrapped: any = {};
|
||||||
|
for (const key in impl) {
|
||||||
|
wrapped[key] = async (call: any, callback: any) => {
|
||||||
|
try {
|
||||||
|
const res = await impl[key](call.request);
|
||||||
|
callback(null, res);
|
||||||
|
} catch (err: any) {
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new grpc.Server();
|
||||||
|
server.addService(AdminServiceService, wrap(AdminServiceImplementation));
|
||||||
|
server.addService(EmployeeServiceService, wrap(EmployeeServiceImplementation));
|
||||||
|
server.addService(LeaveServiceService, wrap(LeaveServiceImplementation));
|
||||||
|
server.addService(ManagerServiceService, wrap(ManagerServiceImplementation));
|
||||||
|
server.addService(ReportsServiceService, wrap(ReportsServiceImplementation));
|
||||||
|
server.addService(HRManagerServiceService, wrap(HRManagerServiceImplementation));
|
||||||
|
|
||||||
|
server.bindAsync(`0.0.0.0:${PORT}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
|
||||||
|
if (err) { console.error("Failed to start gRPC server:", err); return; }
|
||||||
|
console.log(`🚀 LMS gRPC Service running on port ${port}`);
|
||||||
|
});
|
||||||
245
services/lms/repositories/lms.repository.ts
Normal file
245
services/lms/repositories/lms.repository.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
// services/lms/repositories/lms.repository.ts
|
||||||
|
import pool from "../db.ts";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CONFIG (Types, Rules, Holidays, Settings)
|
||||||
|
// ==========================================
|
||||||
|
export const insertLeaveType = async (data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[data.company_id, data.name, data.requires_allocation ?? true, data.carry_over_allowed ?? false, data.max_carry_over_days ?? 0.0]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findLeaveTypes = async (companyId: number) => {
|
||||||
|
const [rows] = await pool.execute(`SELECT * FROM leave_types WHERE company_id = ?`, [companyId]);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertPolicyRule = async (data: any) => {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO leave_policy_rules (leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[data.leave_type_id, data.company_id, data.branch_id || null, data.calendar_year, data.yearly_allowance, data.max_days_per_month || null, data.max_consecutive_days || null, data.apply_sandwich_policy ?? false]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkInsertHolidays = async (values: any[]) => {
|
||||||
|
await pool.query(`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`, [values]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertWorkSettings = async (data: any) => {
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
|
||||||
|
[data.company_id, data.branch_id || null, data.offDaysJson, data.effective_from]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EMPLOYEE DASHBOARD
|
||||||
|
// ==========================================
|
||||||
|
export const findLeaveBalances = async (employeeId: number, year: number) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT lt.leave_type_id, lt.name AS leave_type_name, la.granted_days, la.used_days, (la.granted_days - la.used_days) AS available_balance
|
||||||
|
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
WHERE la.employee_id = ? AND la.calendar_year = ? AND la.status = 'ACTIVE'`,
|
||||||
|
[employeeId, year]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findLeaveHistory = async (employeeId: number, year: number) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT la.application_id, lt.name AS leave_type_name, DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from, DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to, la.number_of_days, la.status, la.reason
|
||||||
|
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
WHERE la.employee_id = ? AND YEAR(la.date_from) = ? ORDER BY la.date_from DESC`,
|
||||||
|
[employeeId, year]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findOptionalHolidays = async (companyId: number, branchId: number | null, year: number, today: string) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT holiday_id, holiday_name, DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
|
||||||
|
FROM company_holidays
|
||||||
|
WHERE company_id = ? AND calendar_year = ? AND holiday_type = 'OPTIONAL' AND (branch_id = ? OR branch_id IS NULL) AND holiday_date >= ?
|
||||||
|
ORDER BY holiday_date ASC`,
|
||||||
|
[companyId, year, branchId, today]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LEAVE APPLICATION
|
||||||
|
// ==========================================
|
||||||
|
export const findWorkSettings = async (companyId: number, branchId: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
||||||
|
[companyId, branchId]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findHolidays = async (companyId: number, year: number, branchId: number) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
|
||||||
|
[companyId, year, branchId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPolicyRule = async (leaveTypeId: number, companyId: number, year: number, branchId: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
||||||
|
[leaveTypeId, companyId, year, branchId]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findRawBalance = async (employeeId: number, leaveTypeId: number, year: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
|
||||||
|
[employeeId, leaveTypeId, year]
|
||||||
|
);
|
||||||
|
return rows[0]?.raw_balance || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findPendingDays = async (employeeId: number, leaveTypeId: number, year: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
||||||
|
[employeeId, leaveTypeId, year]
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.pending_days || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findMonthlyUsage = async (employeeId: number, leaveTypeId: number, month: number, year: number) => {
|
||||||
|
const [rows]: any = await pool.execute(
|
||||||
|
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
|
||||||
|
[employeeId, leaveTypeId, month, year]
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.used_this_month || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update insertLeaveApplication
|
||||||
|
export const insertLeaveApplication = async (data: any, conn: any) => {
|
||||||
|
const [result] = await conn.execute(
|
||||||
|
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by, applicant_role, approver_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[data.employee_id, data.leave_type_id, data.company_id, data.date_from, data.date_to, data.number_of_days, data.reason, data.status, data.manager_id, data.applicant_role, data.approver_role]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MANAGER ACTIONS
|
||||||
|
// ==========================================
|
||||||
|
export const findPendingManagerLeaves = async (managerId: number) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT la.application_id, la.employee_id, la.leave_type_id, lt.name AS leave_type, la.date_from, la.date_to, la.number_of_days, la.reason, la.status
|
||||||
|
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
WHERE la.manager_approved_by = ? AND la.status IN ('PENDING', 'PENDING_LOP') ORDER BY la.date_from ASC`,
|
||||||
|
[managerId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findLeaveForUpdate = async (applicationId: number, conn: any) => {
|
||||||
|
const [rows]: any = await conn.execute(`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`, [applicationId]);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateLeaveStatus = async (applicationId: number, status: string, conn: any) => {
|
||||||
|
await conn.execute(`UPDATE leave_applications SET status = ? WHERE application_id = ?`, [status, applicationId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAllocForUpdate = async (employeeId: number, leaveTypeId: number, year: number, conn: any) => {
|
||||||
|
const [rows]: any = await conn.execute(
|
||||||
|
`SELECT granted_days, used_days FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
|
||||||
|
[employeeId, leaveTypeId, year]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertLedgerTransaction = async (data: any, conn: any) => {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO leave_ledger_transactions (employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks) VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
|
||||||
|
[data.employee_id, data.leave_type_id, data.application_id, data.year, data.month, data.days, data.opening_balance, data.closing_balance, data.remarks]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAllocationUsedDays = async (employeeId: number, leaveTypeId: number, year: number, days: number, conn: any) => {
|
||||||
|
await conn.execute(`UPDATE leave_allocations SET used_days = used_days + ? WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`, [days, employeeId, leaveTypeId, year]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update rejectLeaveApplication to allow any authorized role to reject
|
||||||
|
export const rejectLeaveApplication = async (applicationId: number, approverRole: string) => {
|
||||||
|
let query = `UPDATE leave_applications SET status = 'REJECTED' WHERE application_id = ? AND status IN ('PENDING', 'PENDING_LOP') AND approver_role = ?`;
|
||||||
|
const [result] = await pool.execute(query, [applicationId, approverRole]);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REPORTS
|
||||||
|
// ==========================================
|
||||||
|
export const findLedgerReportData = async (year: number, month: number, companyId: number) => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT la.employee_id, lt.name AS leave_type, la.granted_days AS total_granted, la.used_days AS total_used,
|
||||||
|
COALESCE((SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?)) ORDER BY t_ob.transaction_date DESC LIMIT 1), la.granted_days) AS opening_balance,
|
||||||
|
COALESCE((SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?)) ORDER BY t_cb.transaction_date DESC LIMIT 1), la.granted_days) AS closing_balance
|
||||||
|
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
WHERE la.calendar_year = ? AND la.company_id = ?`,
|
||||||
|
[year, year, month, year, year, month, year, companyId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HR MANAGER ACTIONS
|
||||||
|
// ==========================================
|
||||||
|
export const findAllApplications = async () => {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT la.application_id, la.employee_id, lt.name AS leave_type,
|
||||||
|
DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from,
|
||||||
|
DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to,
|
||||||
|
la.number_of_days, la.status, la.reason
|
||||||
|
FROM leave_applications la
|
||||||
|
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
ORDER BY la.date_from DESC`
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace findPendingManagerLeaves with dynamic routing
|
||||||
|
export const findPendingForApprover = async (approverId: number, approverRole: string) => {
|
||||||
|
let query = `SELECT la.application_id, la.employee_id, la.leave_type_id, lt.name AS leave_type, la.date_from, la.date_to, la.number_of_days, la.reason, la.status
|
||||||
|
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id WHERE la.status IN ('PENDING', 'PENDING_LOP')`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (approverRole === 'MANAGER') {
|
||||||
|
query += ` AND la.approver_role = 'MANAGER' AND la.manager_approved_by = ?`;
|
||||||
|
params.push(approverId);
|
||||||
|
} else if (approverRole === 'HR_MANAGER') {
|
||||||
|
query += ` AND la.approver_role = 'HR_MANAGER'`;
|
||||||
|
} else if (approverRole === 'DIRECTOR') {
|
||||||
|
query += ` AND la.approver_role = 'DIRECTOR'`;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY la.date_from ASC`;
|
||||||
|
const [rows] = await pool.execute(query, params);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
25
services/lms/services/admin.service.ts
Normal file
25
services/lms/services/admin.service.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// services/lms/services/admin.service.ts
|
||||||
|
import * as LmsRepo from "../repositories/lms.repository.ts";
|
||||||
|
|
||||||
|
export const createLeaveType = async (data: any) => {
|
||||||
|
return await LmsRepo.insertLeaveType(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLeaveTypes = async (companyId: number) => {
|
||||||
|
return await LmsRepo.findLeaveTypes(companyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPolicyRule = async (data: any) => {
|
||||||
|
return await LmsRepo.insertPolicyRule(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCompanyHoliday = async (body: any[]) => {
|
||||||
|
if (!Array.isArray(body)) throw new Error("Expected an array of holidays.");
|
||||||
|
const values = body.map(h => [h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name]);
|
||||||
|
return await LmsRepo.bulkInsertHolidays(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkSettings = async (data: any) => {
|
||||||
|
const offDaysJson = JSON.stringify(data.weekly_off_days);
|
||||||
|
return await LmsRepo.upsertWorkSettings({ ...data, offDaysJson });
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user