Compare commits
6 Commits
1b69e9f14c
...
db33948184
| Author | SHA1 | Date | |
|---|---|---|---|
| db33948184 | |||
| ec407364d9 | |||
| 8e6574c0b2 | |||
| 513dc81931 | |||
| 7e48098be8 | |||
| 4d70938a81 |
@ -1,8 +1,13 @@
|
|||||||
import { Context } from "@oak/oak";
|
import { Context } from "@oak/oak";
|
||||||
import { getDbPool } from "../../shared/db.ts";
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
import { AppRole } from "../../shared/auth.ts"; // Importing roles for security checks
|
||||||
|
|
||||||
const amsDb = getDbPool("hrms_ams");
|
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) => {
|
export const createRegularizationRequest = async (ctx: Context) => {
|
||||||
if (!ctx.request.hasBody) {
|
if (!ctx.request.hasBody) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
@ -11,40 +16,143 @@ export const createRegularizationRequest = async (ctx: Context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { attendance_id, employee_id, requested_check_in, requested_check_out, reason } = body;
|
const {
|
||||||
|
attendance_id,
|
||||||
|
employee_id,
|
||||||
|
regularization_type,
|
||||||
|
target_date,
|
||||||
|
requested_check_in,
|
||||||
|
requested_check_out,
|
||||||
|
reason,
|
||||||
|
} = body;
|
||||||
|
|
||||||
// Basic validation
|
if (!employee_id || !reason || !regularization_type || !target_date) {
|
||||||
if (!attendance_id || !employee_id || !reason || !requested_check_in) {
|
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { success: false, message: "Missing required fields: attendance_id, employee_id, requested_check_in, and reason." };
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Missing required fields: employee_id, target_date, regularization_type, and reason.",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract 'YYYY-MM-DD' safely from the requested_check_in string
|
// Fetch employee's branch_id from EMS so AMS knows which branch this request belongs to
|
||||||
const target_date = requested_check_in.split(' ')[0];
|
const [empRows]: any = await emsDb.execute(
|
||||||
|
`SELECT branch_id FROM employees WHERE employee_id = ?`,
|
||||||
|
[employee_id],
|
||||||
|
);
|
||||||
|
|
||||||
// Insert into the attendance_regularizations table including target_date
|
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(
|
await amsDb.execute(
|
||||||
`INSERT INTO attendance_regularizations
|
`INSERT INTO attendance_regularizations
|
||||||
(attendance_id, employee_id, target_date, requested_check_in, requested_check_out, reason, status)
|
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'PENDING')`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
|
||||||
[attendance_id, employee_id, target_date, requested_check_in, requested_check_out || null, reason]
|
[
|
||||||
|
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.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Regularization request submitted successfully and is pending approval.",
|
message:
|
||||||
|
"Regularization request submitted successfully and is pending approval.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to submit regularization:", error);
|
console.error("Failed to submit regularization:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
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) => {
|
export const reviewRegularizationRequest = async (ctx: Context) => {
|
||||||
if (!ctx.request.hasBody) {
|
if (!ctx.request.hasBody) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
@ -53,11 +161,15 @@ export const reviewRegularizationRequest = async (ctx: Context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { regularization_id, action, reviewed_by_id } = body; // action can be 'APPROVED' or 'REJECTED'
|
const user = ctx.state.user; // The admin reviewing it
|
||||||
|
const { regularization_id, action } = body; // action can be 'APPROVED' or 'REJECTED'
|
||||||
|
|
||||||
if (!regularization_id || !reviewed_by_id || !["APPROVED", "REJECTED"].includes(action)) {
|
if (!regularization_id || !["APPROVED", "REJECTED"].includes(action)) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { success: false, message: "Invalid payload. Required: regularization_id, reviewed_by_id, action." };
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid payload. Required: regularization_id, action.",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,81 +180,119 @@ export const reviewRegularizationRequest = async (ctx: Context) => {
|
|||||||
|
|
||||||
// 1. Fetch the regularization request details
|
// 1. Fetch the regularization request details
|
||||||
const [requestRows]: any = await amsConnection.execute(
|
const [requestRows]: any = await amsConnection.execute(
|
||||||
`SELECT
|
`SELECT regularization_id, attendance_id, branch_id, requested_check_in, requested_check_out, status
|
||||||
attendance_id,
|
|
||||||
DATE_FORMAT(requested_check_in, '%Y-%m-%d %H:%i:%s') as requested_check_in,
|
|
||||||
DATE_FORMAT(requested_check_out, '%Y-%m-%d %H:%i:%s') as requested_check_out,
|
|
||||||
status
|
|
||||||
FROM attendance_regularizations WHERE regularization_id = ?`,
|
FROM attendance_regularizations WHERE regularization_id = ?`,
|
||||||
[regularization_id]
|
[regularization_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requestRows.length === 0) {
|
if (requestRows.length === 0) {
|
||||||
ctx.response.status = 404;
|
ctx.response.status = 404;
|
||||||
ctx.response.body = { success: false, message: "Regularization request not found." };
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Regularization request not found.",
|
||||||
|
};
|
||||||
await amsConnection.rollback();
|
await amsConnection.rollback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = requestRows[0];
|
const request = requestRows[0];
|
||||||
|
|
||||||
if (request.status !== "PENDING") {
|
// SECURITY CHECK: Ensure the admin actually manages this branch
|
||||||
ctx.response.status = 400;
|
if (
|
||||||
ctx.response.body = { success: false, message: "This request has already been processed." };
|
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();
|
await amsConnection.rollback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update the regularization request record status and reviewer ID (Removed 'remarks')
|
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(
|
await amsConnection.execute(
|
||||||
`UPDATE attendance_regularizations
|
`UPDATE attendance_regularizations
|
||||||
SET status = ?, reviewed_by_id = ?
|
SET status = ?, reviewed_by_id = ?
|
||||||
WHERE regularization_id = ?`,
|
WHERE regularization_id = ?`,
|
||||||
[action, reviewed_by_id, regularization_id]
|
[action, user.employee_id, regularization_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. If APPROVED, dynamically overwrite the target day's calculated ledger row
|
// 3. If APPROVED, dynamically recalculate and overwrite the target day's processed ledger row
|
||||||
if (action === "APPROVED") {
|
if (action === "APPROVED") {
|
||||||
let worked_hours = 0.0;
|
let worked_hours = 0.0;
|
||||||
let final_status = "FULL_DAY";
|
let final_status = "FULL_DAY";
|
||||||
let check_in_status = "ON_TIME";
|
let check_in_status = "ON_TIME";
|
||||||
|
|
||||||
// Fetch shift details to accurately re-evaluate punctuality boundaries (Defaulting to shift_id 1)
|
// Fetch shift details (Fallback pattern)
|
||||||
const [shiftRows]: any = await amsConnection.execute(
|
const [empRows]: any = await amsConnection.execute(
|
||||||
`SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
|
`SELECT company_id, branch_id FROM hrms_ems.employees WHERE employee_id = ?`,
|
||||||
|
[requestRows[0].employee_id],
|
||||||
);
|
);
|
||||||
const shift = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
|
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) {
|
if (request.requested_check_in && request.requested_check_out) {
|
||||||
// --- A. Recalculate Working Duration Metrics ---
|
const checkInMs = new Date(request.requested_check_in.replace(" ", "T"))
|
||||||
const checkInMs = new Date(request.requested_check_in.replace(' ', 'T')).getTime();
|
.getTime();
|
||||||
const checkOutMs = new Date(request.requested_check_out.replace(' ', 'T')).getTime();
|
const checkOutMs = new Date(
|
||||||
worked_hours = Math.round(((checkOutMs - checkInMs) / (1000 * 60 * 60)) * 100) / 100;
|
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";
|
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
||||||
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
||||||
else final_status = "ABSENT";
|
else final_status = "ABSENT";
|
||||||
|
|
||||||
// --- B. Dynamically Re-evaluate Punctuality Status (Preserves LATE flags) ---
|
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
|
||||||
const rawCheckInTimeStr = request.requested_check_in.split(' ')[1];
|
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(
|
||||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
Number,
|
||||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
);
|
||||||
|
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(
|
||||||
|
Number,
|
||||||
|
);
|
||||||
|
|
||||||
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
||||||
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (shift.grace_period_minutes * 60);
|
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 +
|
||||||
|
(shift.grace_period_minutes * 60);
|
||||||
|
|
||||||
if (punchTotalSeconds > shiftCutoffSeconds) {
|
if (punchTotalSeconds > shiftCutoffSeconds) {
|
||||||
check_in_status = "LATE";
|
check_in_status = "LATE";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only update the ledger if an actual attendance record exists
|
||||||
|
if (request.attendance_id) {
|
||||||
await amsConnection.execute(
|
await amsConnection.execute(
|
||||||
`UPDATE processed_daily_attendance
|
`UPDATE processed_daily_attendance
|
||||||
SET check_in = ?,
|
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
|
||||||
check_out = ?,
|
|
||||||
worked_hours = ?,
|
|
||||||
final_status = ?,
|
|
||||||
check_in_status = ?
|
|
||||||
WHERE attendance_id = ?`,
|
WHERE attendance_id = ?`,
|
||||||
[
|
[
|
||||||
request.requested_check_in,
|
request.requested_check_in,
|
||||||
@ -150,23 +300,26 @@ export const reviewRegularizationRequest = async (ctx: Context) => {
|
|||||||
worked_hours,
|
worked_hours,
|
||||||
final_status,
|
final_status,
|
||||||
check_in_status,
|
check_in_status,
|
||||||
request.attendance_id
|
request.attendance_id,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await amsConnection.commit();
|
await amsConnection.commit();
|
||||||
ctx.response.status = 200;
|
ctx.response.status = 200;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Request has been successfully ${action.toLowerCase()} by admin ID ${reviewed_by_id}.`,
|
message: `Request has been successfully ${action.toLowerCase()}.`,
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await amsConnection.rollback();
|
await amsConnection.rollback();
|
||||||
console.error("Admin review action failed:", error);
|
console.error("Admin review action failed:", error);
|
||||||
ctx.response.status = 500;
|
ctx.response.status = 500;
|
||||||
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
amsConnection.release();
|
amsConnection.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import {
|
|||||||
getDailyReport,
|
getDailyReport,
|
||||||
getAdminRangeReport,
|
getAdminRangeReport,
|
||||||
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
||||||
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
import {createRegularizationRequest,
|
||||||
|
getPendingRegularizations,
|
||||||
|
reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
||||||
|
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
const apiV1 = new Router();
|
const apiV1 = new Router();
|
||||||
@ -14,8 +17,14 @@ const apiV1 = new Router();
|
|||||||
apiV1.get("/attendance/logs", getRawLogs);
|
apiV1.get("/attendance/logs", getRawLogs);
|
||||||
apiV1.post("/attendance/process-daily", processDailyAttendance);
|
apiV1.post("/attendance/process-daily", processDailyAttendance);
|
||||||
|
|
||||||
apiV1.post("/attendance/regularize", createRegularizationRequest);
|
// Regularization Routes
|
||||||
apiV1.post("/attendance/regularize/review", reviewRegularizationRequest);
|
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/my-summary", getEmployeeSummary);
|
||||||
|
|
||||||
|
|||||||
@ -63,12 +63,14 @@ export const createContract = async (ctx: any) => {
|
|||||||
|
|
||||||
// 2. Insert the brand new active contract
|
// 2. Insert the brand new active contract
|
||||||
const [result] = await connection.execute(
|
const [result] = await connection.execute(
|
||||||
`INSERT INTO contracts (employee_id, department_id, job_id, date_joining, probation_days, status, salary_structure_id)
|
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, reporting_to_id, date_joining, probation_days, status, salary_structure_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
data.employeeId,
|
data.employeeId,
|
||||||
data.departmentId,
|
data.departmentId,
|
||||||
data.jobId,
|
data.jobId,
|
||||||
|
data.workEmail, // <-- Added
|
||||||
|
data.reportingToId, // <-- Added
|
||||||
data.dateJoining,
|
data.dateJoining,
|
||||||
data.probationDays,
|
data.probationDays,
|
||||||
'ACTIVE',
|
'ACTIVE',
|
||||||
|
|||||||
@ -279,20 +279,43 @@ export const updateEmployee = async (ctx: any) => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// Upsert Address (Fixes the missing address bug)
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
`UPDATE addresses
|
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
||||||
SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
WHERE partner_id = ? AND address_type = ?`,
|
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.address.type,
|
||||||
data.address.doorNumber,
|
data.address.doorNumber,
|
||||||
data.address.landmark,
|
data.address.landmark,
|
||||||
data.address.line,
|
data.address.line,
|
||||||
data.address.pincode,
|
data.address.pincode,
|
||||||
data.address.district,
|
data.address.district,
|
||||||
data.address.state,
|
data.address.state,
|
||||||
partnerId,
|
]
|
||||||
data.address.type,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|||||||
108
init-db/04-init-lms.sql
Normal file
108
init-db/04-init-lms.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS hrms_lms;
|
||||||
|
USE hrms_lms;
|
||||||
|
|
||||||
|
-- 1. Leave Categorization Profiles
|
||||||
|
-- Defines the types of leaves available (e.g., Casual Leave, Optional Leave)
|
||||||
|
CREATE TABLE leave_types (
|
||||||
|
leave_type_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL, -- Logical Reference to EMS companies table
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
requires_allocation BOOLEAN DEFAULT TRUE,
|
||||||
|
carry_over_allowed BOOLEAN DEFAULT FALSE,
|
||||||
|
max_carry_over_days DECIMAL(3,1) DEFAULT 0.0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 2. Dynamic Rules Engine (Configuration-Driven Policies)
|
||||||
|
-- Replaces hardcoded values. Allows HR to configure yearly limits, monthly caps, and the Sandwich Policy dynamically.
|
||||||
|
CREATE TABLE leave_policy_rules (
|
||||||
|
rule_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
leave_type_id INT NOT NULL,
|
||||||
|
company_id INT NOT NULL,
|
||||||
|
branch_id INT NULL, -- NULL = Global Rule, INT = Branch-Specific Rule overrides global
|
||||||
|
calendar_year INT NOT NULL, -- Allows policy changes year-over-year
|
||||||
|
yearly_allowance DECIMAL(4,1) NOT NULL, -- e.g., 10.0 for Casual, 4.0 for Optional
|
||||||
|
max_days_per_month DECIMAL(4,1) NULL, -- e.g., 2.0 max per month
|
||||||
|
max_consecutive_days DECIMAL(4,1) NULL,
|
||||||
|
apply_sandwich_policy BOOLEAN DEFAULT FALSE, -- Toggles the weekend sandwich logic
|
||||||
|
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_policy_lookup (company_id, branch_id, calendar_year, leave_type_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 3. Leave Balance Summary
|
||||||
|
-- Provides a quick lookup for the frontend dashboard to display current available balances
|
||||||
|
CREATE TABLE leave_allocations (
|
||||||
|
allocation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL, -- Logical Reference to EMS employees table
|
||||||
|
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') DEFAULT 'ACTIVE',
|
||||||
|
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id) ON DELETE RESTRICT,
|
||||||
|
INDEX idx_emp_balance (employee_id, calendar_year, leave_type_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 4. Leave Applications
|
||||||
|
-- 1 Row = 1 Leave Type. Uses DECIMAL(4,1) to support 0.5 half-days.
|
||||||
|
-- Statuses updated to explicitly show PENDING vs PENDING_LOP to the employee.
|
||||||
|
CREATE TABLE leave_applications (
|
||||||
|
application_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
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, -- e.g., 0.5, 1.0, 2.5
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 5. Immutable Leave Ledger (Replaces Excel OB/CB tracking)
|
||||||
|
-- Every credit (probation/new year) and debit (approved leave) creates a transaction.
|
||||||
|
CREATE TABLE leave_ledger_transactions (
|
||||||
|
transaction_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL,
|
||||||
|
leave_type_id INT NOT NULL,
|
||||||
|
application_id INT NULL, -- NULL if this is an automated system credit
|
||||||
|
calendar_year INT NOT NULL,
|
||||||
|
calendar_month INT NOT NULL, -- Used to instantly query the Excel-style "May OB" / "May CB" reports
|
||||||
|
transaction_type ENUM('CREDIT', 'DEBIT', 'LAPSE') 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 DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
remarks VARCHAR(255),
|
||||||
|
FOREIGN KEY (leave_type_id) REFERENCES leave_types(leave_type_id),
|
||||||
|
FOREIGN KEY (application_id) REFERENCES leave_applications(application_id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_ledger_lookup (employee_id, calendar_year, calendar_month)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 6. Company Operational Holidays
|
||||||
|
-- Manages both Mandatory fixed holidays and the Optional holiday list.
|
||||||
|
-- Alternate working Saturdays are managed here by omitting them or marking them as holidays.
|
||||||
|
CREATE TABLE company_holidays (
|
||||||
|
holiday_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL,
|
||||||
|
branch_id INT NULL, -- NULL = Global holiday, INT = Branch-specific holiday
|
||||||
|
calendar_year INT NOT NULL,
|
||||||
|
holiday_date DATE NOT NULL,
|
||||||
|
holiday_type ENUM('MANDATORY', 'OPTIONAL') NOT NULL DEFAULT 'MANDATORY',
|
||||||
|
holiday_name VARCHAR(100) NOT NULL,
|
||||||
|
INDEX idx_holiday_resolver (company_id, branch_id, calendar_year, holiday_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 7. Customizable Workweek Settings
|
||||||
|
-- Prevents hardcoding "Sunday is off". Defines the default weekly off days using JavaScript Date.getDay() indexes.
|
||||||
|
CREATE TABLE branch_work_settings (
|
||||||
|
setting_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL,
|
||||||
|
branch_id INT NULL, -- NULL = Global fallback
|
||||||
|
weekly_off_days JSON NOT NULL, -- Stores array: '[0]' for Sunday, or '[0, 6]' for Sat/Sun
|
||||||
|
effective_from DATE NOT NULL,
|
||||||
|
INDEX idx_work_settings (company_id, branch_id)
|
||||||
|
) 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,5 +1,6 @@
|
|||||||
// shared/auth.ts
|
// shared/auth.ts
|
||||||
import { Context, Next } from "@oak/oak";
|
import { Context, Next } from "@oak/oak";
|
||||||
|
import { getDbPool } from "./db.ts";
|
||||||
|
|
||||||
export enum AppRole {
|
export enum AppRole {
|
||||||
SUPER_ADMIN = "SUPER_ADMIN",
|
SUPER_ADMIN = "SUPER_ADMIN",
|
||||||
@ -8,35 +9,49 @@ export enum AppRole {
|
|||||||
EMPLOYEE = "EMPLOYEE",
|
EMPLOYEE = "EMPLOYEE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Authentication Middleware (Who are you?)
|
// 1. Authentication Middleware
|
||||||
export const requireAuth = async (ctx: Context, next: Next) => {
|
export const requireAuth = async (ctx: Context, next: Next) => {
|
||||||
// Mocking the authorization for now.
|
const isAuthenticated = true;
|
||||||
// Later, we will extract the JWT from ctx.request.headers.get("Authorization")
|
|
||||||
// and verify it with your SSO provider here.
|
|
||||||
|
|
||||||
const isAuthenticated = true; // Simulating a successful login
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
ctx.response.status = 401;
|
ctx.response.status = 401;
|
||||||
ctx.response.body = { success: false, message: "Missing or invalid token" };
|
ctx.response.body = { success: false, message: "Missing or invalid token" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Injecting a mock user state so the next middleware can read it
|
// FIX: Explicitly define the type as AppRole so TypeScript allows comparisons
|
||||||
|
const mockRole: AppRole = AppRole.ADMIN;
|
||||||
|
const mockEmployeeId = 135;
|
||||||
|
|
||||||
|
let managedBranches: number[] = [];
|
||||||
|
|
||||||
|
if (mockRole !== AppRole.SUPER_ADMIN) {
|
||||||
|
try {
|
||||||
|
const emsDb = getDbPool("hrms_ems");
|
||||||
|
const [rows]: any = await emsDb.execute(
|
||||||
|
`SELECT branch_id FROM branch_admins WHERE employee_id = ?`,
|
||||||
|
[mockEmployeeId]
|
||||||
|
);
|
||||||
|
managedBranches = rows.map((r: any) => r.branch_id);
|
||||||
|
|
||||||
|
if (managedBranches.length === 0) managedBranches = [1];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch branch admin mappings", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.state.user = {
|
ctx.state.user = {
|
||||||
employee_id: 135, // Example ID
|
employee_id: mockEmployeeId,
|
||||||
role: AppRole.SUPER_ADMIN, // Change this to test different access levels
|
role: mockRole,
|
||||||
branch_id: 1
|
managed_branches: managedBranches
|
||||||
};
|
};
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Authorization Middleware (What are you allowed to do?)
|
// 2. Authorization Middleware
|
||||||
export const requireRole = (allowedRoles: AppRole[]) => {
|
export const requireRole = (allowedRoles: AppRole[]) => {
|
||||||
return async (ctx: Context, next: Next) => {
|
return async (ctx: Context, next: Next) => {
|
||||||
const userRole = ctx.state.user?.role;
|
const userRole = ctx.state.user?.role;
|
||||||
|
|
||||||
if (!userRole || !allowedRoles.includes(userRole)) {
|
if (!userRole || !allowedRoles.includes(userRole)) {
|
||||||
ctx.response.status = 403;
|
ctx.response.status = 403;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
@ -45,7 +60,6 @@ export const requireRole = (allowedRoles: AppRole[]) => {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
116
shared/calendar.service.ts
Normal file
116
shared/calendar.service.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
// shared/calendar.service.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interfaces representing the database structures needed for calculations.
|
||||||
|
*/
|
||||||
|
export interface WorkSettings {
|
||||||
|
weeklyOffDays: number[]; // Array of JS day indexes, e.g., [0] for Sunday
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyHoliday {
|
||||||
|
holiday_date: Date | string; // Handled as Date object or YYYY-MM-DD string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a Date object to midnight (00:00:00) to ensure accurate comparisons
|
||||||
|
* without timezone or time-of-day interference.
|
||||||
|
*/
|
||||||
|
export function normalizeDate(date: Date | string): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific date is a working day by verifying it against
|
||||||
|
* the branch's weekly off days and the company's holiday calendar.
|
||||||
|
*/
|
||||||
|
export function isWorkingDay(
|
||||||
|
targetDate: Date | string,
|
||||||
|
settings: WorkSettings,
|
||||||
|
holidays: CompanyHoliday[]
|
||||||
|
): boolean {
|
||||||
|
const date = normalizeDate(targetDate);
|
||||||
|
const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||||
|
|
||||||
|
// 1. Check if it's a standard weekly off (e.g., Sunday)
|
||||||
|
if (settings.weeklyOffDays.includes(dayOfWeek)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if it's a designated company holiday
|
||||||
|
const targetDateString = date.toISOString().split("T")[0];
|
||||||
|
const isHoliday = holidays.some((holiday) => {
|
||||||
|
const holidayDate = normalizeDate(holiday.holiday_date);
|
||||||
|
return holidayDate.toISOString().split("T")[0] === targetDateString;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isHoliday) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the total number of deductible leave days between two dates.
|
||||||
|
* It strictly applies the Sandwich Policy if configured by the rule engine.
|
||||||
|
*/
|
||||||
|
export function calculateLeaveDays(
|
||||||
|
startDate: Date | string,
|
||||||
|
endDate: Date | string,
|
||||||
|
settings: WorkSettings,
|
||||||
|
holidays: CompanyHoliday[],
|
||||||
|
applySandwichPolicy: boolean
|
||||||
|
): number {
|
||||||
|
const start = normalizeDate(startDate);
|
||||||
|
const end = normalizeDate(endDate);
|
||||||
|
|
||||||
|
// Failsafe for incorrect date ordering
|
||||||
|
if (start > end) {
|
||||||
|
throw new Error("startDate cannot be after endDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeductibleDays = 0;
|
||||||
|
const currentDate = new Date(start);
|
||||||
|
|
||||||
|
while (currentDate <= end) {
|
||||||
|
const isWorking = isWorkingDay(currentDate, settings, holidays);
|
||||||
|
|
||||||
|
if (applySandwichPolicy) {
|
||||||
|
// SANDWICH LOGIC:
|
||||||
|
// If the policy is active, every day inside the requested block is counted as a leave,
|
||||||
|
// even if it falls on a weekend or holiday.
|
||||||
|
totalDeductibleDays += 1;
|
||||||
|
} else {
|
||||||
|
// STANDARD LOGIC:
|
||||||
|
// Only count the day if it is an actual working day. Ignore weekends and holidays.
|
||||||
|
if (isWorking) {
|
||||||
|
totalDeductibleDays += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next day
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDeductibleDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper utility specifically for the AMS (Attendance) module.
|
||||||
|
* Generates an array of all dates between two points, used to cross-reference
|
||||||
|
* expected attendance against raw biometric logs.
|
||||||
|
*/
|
||||||
|
export function getDatesInRange(startDate: Date | string, endDate: Date | string): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const currentDate = normalizeDate(startDate);
|
||||||
|
const end = normalizeDate(endDate);
|
||||||
|
|
||||||
|
while (currentDate <= end) {
|
||||||
|
dates.push(new Date(currentDate));
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user