Compare commits
No commits in common. "ab1f9444f9bbeac32ad692487e086ef53cc67409" and "8e6574c0b2de420557f4e88787296c17ec955981" have entirely different histories.
ab1f9444f9
...
8e6574c0b2
33
.env
33
.env
@ -1,33 +0,0 @@
|
||||
# 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
18
Dockerfile
@ -1,18 +0,0 @@
|
||||
# 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"]
|
||||
@ -1,19 +0,0 @@
|
||||
# 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,68 +1,2 @@
|
||||
# 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`.
|
||||
|
||||
519
ams-service/controllers/attendance.controller.ts
Normal file
519
ams-service/controllers/attendance.controller.ts
Normal file
@ -0,0 +1,519 @@
|
||||
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" };
|
||||
}
|
||||
};
|
||||
326
ams-service/controllers/regularization.controller.ts
Normal file
326
ams-service/controllers/regularization.controller.ts
Normal file
@ -0,0 +1,326 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
16
ams-service/main.ts
Normal file
16
ams-service/main.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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 });
|
||||
37
ams-service/routes.ts
Normal file
37
ams-service/routes.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,253 +0,0 @@
|
||||
-- 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
|
||||
@ -1,8 +0,0 @@
|
||||
# 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
|
||||
@ -1,8 +0,0 @@
|
||||
# 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,18 +1,12 @@
|
||||
{
|
||||
"workspace": [
|
||||
"packages/core",
|
||||
"services/ems",
|
||||
"services/ams",
|
||||
"services/lms",
|
||||
"services/gateway"
|
||||
"./shared",
|
||||
"./ems-service",
|
||||
"./ams-service",
|
||||
"./lms-service"
|
||||
],
|
||||
"imports": {
|
||||
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||
"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"
|
||||
"mysql2": "npm:mysql2@^3.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
ems_db:
|
||||
hrms_db:
|
||||
image: docker.io/library/mysql:8.4
|
||||
container_name: hrms_ems_db
|
||||
container_name: hrms_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hrms_ems
|
||||
@ -12,54 +12,9 @@ services:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./init-db/01-init-ems.sql:/docker-entrypoint-initdb.d/01-init-ems.sql:Z
|
||||
- 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
|
||||
- ./init-db:/docker-entrypoint-initdb.d:Z
|
||||
- hrms_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ems_db_data:
|
||||
ams_db_data:
|
||||
lms_db_data:
|
||||
hrms_db_data:
|
||||
98
ems-service/controllers/contract.controller.ts
Normal file
98
ems-service/controllers/contract.controller.ts
Normal file
@ -0,0 +1,98 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
346
ems-service/controllers/employee.controller.ts
Normal file
346
ems-service/controllers/employee.controller.ts
Normal file
@ -0,0 +1,346 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
443
ems-service/controllers/lookup.controller.ts
Normal file
443
ems-service/controllers/lookup.controller.ts
Normal file
@ -0,0 +1,443 @@
|
||||
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 };
|
||||
}
|
||||
};
|
||||
173
ems-service/controllers/system.controller.ts
Normal file
173
ems-service/controllers/system.controller.ts
Normal file
@ -0,0 +1,173 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
11
ems-service/main.ts
Normal file
11
ems-service/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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 });
|
||||
100
ems-service/routes.ts
Normal file
100
ems-service/routes.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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;
|
||||
69
ems-service/routes.ts.bkp
Normal file
69
ems-service/routes.ts.bkp
Normal file
@ -0,0 +1,69 @@
|
||||
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
5006
generated/ams.ts
File diff suppressed because it is too large
Load Diff
7257
generated/ems.ts
7257
generated/ems.ts
File diff suppressed because it is too large
Load Diff
4558
generated/lms.ts
4558
generated/lms.ts
File diff suppressed because it is too large
Load Diff
@ -4,10 +4,8 @@ USE hrms_ems;
|
||||
-- 1. Legal Entities
|
||||
CREATE TABLE companies (
|
||||
company_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
parent_id INT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES companies(company_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -18,7 +16,6 @@ CREATE TABLE branches (
|
||||
company_id INT NOT NULL,
|
||||
branch_name VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -47,7 +44,6 @@ CREATE TABLE addresses (
|
||||
pincode VARCHAR(10) NOT NULL,
|
||||
district 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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@ -56,10 +52,8 @@ CREATE TABLE departments (
|
||||
department_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT NOT NULL,
|
||||
branch_id INT NOT NULL,
|
||||
department_code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
parent_id INT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(company_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
|
||||
@ -69,10 +63,8 @@ CREATE TABLE departments (
|
||||
CREATE TABLE job_positions (
|
||||
job_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
job_code VARCHAR(20) NOT NULL UNIQUE,
|
||||
title VARCHAR(50) NOT NULL,
|
||||
description TEXT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@ -90,61 +82,34 @@ CREATE TABLE employees (
|
||||
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 8. Legal & Compensation Terms (Changes rarely)
|
||||
CREATE TABLE employment_terms (
|
||||
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,
|
||||
-- 8. Legal & Operational Contracts
|
||||
CREATE TABLE contracts (
|
||||
contract_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
job_id INT NOT NULL,
|
||||
reporting_to_id INT NULL,
|
||||
change_reason ENUM('HIRE', 'TRANSFER', 'PROMOTION', 'MANAGER_CHANGE', 'REORG') NOT NULL,
|
||||
effective_from DATE NOT NULL,
|
||||
effective_to DATE NULL,
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
work_email VARCHAR(100) NOT NULL UNIQUE,
|
||||
date_joining DATE NOT NULL,
|
||||
probation_days INT DEFAULT 90,
|
||||
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
|
||||
salary_structure_id INT NOT NULL,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
|
||||
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 (reporting_to_id) REFERENCES employees(employee_id) ON DELETE SET NULL
|
||||
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 10. Department Managers (Junction Table with History)
|
||||
-- 9. Department Managers (Junction Table)
|
||||
CREATE TABLE department_managers (
|
||||
department_id INT NOT NULL,
|
||||
employee_id INT NOT NULL,
|
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_current BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
removed_at TIMESTAMP NULL,
|
||||
PRIMARY KEY (department_id, employee_id),
|
||||
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 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
|
||||
-- 10. System Sequences
|
||||
CREATE TABLE system_sequences (
|
||||
sequence_id VARCHAR(50) PRIMARY KEY,
|
||||
prefix VARCHAR(10) NOT NULL,
|
||||
|
||||
46
init-db/02-dummy-data.sql
Normal file
46
init-db/02-dummy-data.sql
Normal file
@ -0,0 +1,46 @@
|
||||
-- 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,9 +51,8 @@ CREATE TABLE processed_daily_attendance (
|
||||
CREATE TABLE attendance_regularizations (
|
||||
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
||||
branch_id INT NOT NULL,
|
||||
attendance_id INT NULL, -- Internal FK to processed ledger row
|
||||
regularization_type ENUM('MISPUNCH', 'HALF_DAY', 'WFH_REQUEST', 'ON_DUTY') NOT NULL;
|
||||
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
|
||||
target_date DATE NOT NULL,
|
||||
requested_check_in TIMESTAMP NULL,
|
||||
requested_check_out TIMESTAMP NULL,
|
||||
@ -58,8 +58,6 @@ CREATE TABLE leave_applications (
|
||||
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)
|
||||
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,
|
||||
INDEX idx_lms_status_lookup (employee_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
153
lms-service/controllers/admin.controller.ts
Normal file
153
lms-service/controllers/admin.controller.ts
Normal file
@ -0,0 +1,153 @@
|
||||
// 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" };
|
||||
}
|
||||
};
|
||||
179
lms-service/controllers/employee.controller.ts
Normal file
179
lms-service/controllers/employee.controller.ts
Normal file
@ -0,0 +1,179 @@
|
||||
// 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" };
|
||||
}
|
||||
};
|
||||
184
lms-service/controllers/leave.controller.ts
Normal file
184
lms-service/controllers/leave.controller.ts
Normal file
@ -0,0 +1,184 @@
|
||||
// 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 };
|
||||
}
|
||||
};
|
||||
209
lms-service/controllers/manager.controller.ts
Normal file
209
lms-service/controllers/manager.controller.ts
Normal file
@ -0,0 +1,209 @@
|
||||
// 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" };
|
||||
}
|
||||
};
|
||||
75
lms-service/controllers/reports.controller.ts
Normal file
75
lms-service/controllers/reports.controller.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// 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" };
|
||||
}
|
||||
};
|
||||
16
lms-service/main.ts
Normal file
16
lms-service/main.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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 });
|
||||
62
lms-service/routes.ts
Normal file
62
lms-service/routes.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// 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;
|
||||
@ -1,66 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
};
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@shared/utils",
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
"./auth": "./auth.ts",
|
||||
"./internal-client": "./internal-client.ts",
|
||||
"./calendar": "./calendar.service.ts"
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
// 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
287
protos/ams.proto
@ -1,287 +0,0 @@
|
||||
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
414
protos/ems.proto
@ -1,414 +0,0 @@
|
||||
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
276
protos/lms.proto
@ -1,276 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
#!/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!"
|
||||
@ -1,15 +0,0 @@
|
||||
// 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;
|
||||
@ -1,202 +0,0 @@
|
||||
// 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: "" };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,119 +0,0 @@
|
||||
// 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: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
// 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}`);
|
||||
});
|
||||
@ -1,162 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,55 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,820 +0,0 @@
|
||||
// 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 };
|
||||
};
|
||||
@ -1,102 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@ -1,208 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
// 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;
|
||||
@ -1,37 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,213 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,101 +0,0 @@
|
||||
// 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: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,36 +0,0 @@
|
||||
// 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}`);
|
||||
});
|
||||
@ -1,35 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,194 +0,0 @@
|
||||
// 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]
|
||||
);
|
||||
};
|
||||
@ -1,31 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,189 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,93 +0,0 @@
|
||||
// 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,23 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
// 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 };
|
||||
};
|
||||
@ -1,143 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
@ -1,265 +0,0 @@
|
||||
// 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);
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
@ -1,24 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
};
|
||||
@ -1,50 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
// 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")!,
|
||||
}
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "@service/gateway",
|
||||
"version": "0.1.0",
|
||||
"exports": "./main.ts"
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
// 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();
|
||||
};
|
||||
@ -1,45 +0,0 @@
|
||||
// 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 });
|
||||
@ -1,232 +0,0 @@
|
||||
// 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;
|
||||
@ -1,350 +0,0 @@
|
||||
// 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;
|
||||
@ -1,231 +0,0 @@
|
||||
// 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;
|
||||
@ -1,8 +0,0 @@
|
||||
// services/gateway/utils/http.ts
|
||||
export const getJsonBody = async (ctx: any) => {
|
||||
try {
|
||||
return await ctx.request.body.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
// 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;
|
||||
@ -1,42 +0,0 @@
|
||||
// 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 }; }
|
||||
}
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
// 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: [] }; }
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
// 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: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,40 +0,0 @@
|
||||
// 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 }; }
|
||||
}
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
// 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: [] }; }
|
||||
}
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
// 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}`);
|
||||
});
|
||||
@ -1,245 +0,0 @@
|
||||
// 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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
// 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 });
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
// services/lms/services/employee.service.ts
|
||||
import { AppRole } from "core/auth.ts";
|
||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
||||
|
||||
export const getLeaveBalances = async (user: any, params: any) => {
|
||||
let targetEmployeeId = user.employee_id;
|
||||
if ((user.role === AppRole.HR_MANAGER || user.role === AppRole.DIRECTOR || user.role === AppRole.MANAGER) && params.get("employee_id")) {
|
||||
targetEmployeeId = Number(params.get("employee_id"));
|
||||
}
|
||||
const year = Number(params.get("year")) || new Date().getFullYear();
|
||||
return await LmsRepo.findLeaveBalances(targetEmployeeId, year);
|
||||
};
|
||||
|
||||
export const getLeaveHistory = async (user: any, params: any) => {
|
||||
let targetEmployeeId = user.employee_id;
|
||||
if ((user.role === AppRole.HR_MANAGER || user.role === AppRole.DIRECTOR || user.role === AppRole.MANAGER) && params.get("employee_id")) {
|
||||
targetEmployeeId = Number(params.get("employee_id"));
|
||||
}
|
||||
const year = Number(params.get("year")) || new Date().getFullYear();
|
||||
return await LmsRepo.findLeaveHistory(targetEmployeeId, year);
|
||||
};
|
||||
|
||||
export const getValidOptionalHolidays = async (user: any) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const companyId = user.company_id ?? null;
|
||||
const branchId = user.branch_id ?? null;
|
||||
return await LmsRepo.findOptionalHolidays(companyId, branchId, currentYear, today);
|
||||
};
|
||||
@ -1,24 +0,0 @@
|
||||
import { getEmployeeRoster } from "core/internal-client.ts";
|
||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
||||
|
||||
export const getAllApplications = async () => {
|
||||
const rows = await LmsRepo.findAllApplications();
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
|
||||
const roster = await getEmployeeRoster(employeeIds);
|
||||
const rosterMap = new Map(roster.map((e: any) => [e.employeeId, e]));
|
||||
|
||||
return rows.map((app: any) => {
|
||||
const emp = rosterMap.get(app.employee_id);
|
||||
return {
|
||||
...app,
|
||||
employee_code: emp?.employeeCode || "N/A",
|
||||
first_name: emp?.fullName?.split(" ")[0] || "",
|
||||
last_name: emp?.fullName?.split(" ").slice(1).join(" ") || "",
|
||||
department_name: emp?.departmentName || "N/A",
|
||||
job_title: emp?.jobTitle || "N/A",
|
||||
manager_name: emp?.managerName || "N/A" // <--- ADD THIS LINE!
|
||||
};
|
||||
});
|
||||
};
|
||||
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