Compare commits

..

9 Commits

Author SHA1 Message Date
8e6574c0b2 imlemented attendance regularisation logic 2026-07-09 18:45:46 +05:30
513dc81931 Merge pull request 'feat(lms): implement core Leave Management System microservice with auto-LOP splitting and holiday calendars' (#11) from feature/lms-module into main
Reviewed-on: #11
Merge branch 'feature/lms-module': Add Leave Management System (LMS) microservice

* Introduces LMS controllers, routes, and Deno server on port 8003
* Adds schema initialization for `hrms_lms` database
* Implements leave application engine with auto-LOP calculation and sandwich policy
* Adds shared calendar service for holiday and weekend deductions
* Establishes employee, manager, and admin API endpoints
2026-07-09 18:34:37 +05:30
7e48098be8 feat(lms): implement core Leave Management System microservice with auto-LOP splitting and holiday calendars 2026-07-09 18:41:20 +05:30
4d70938a81 Merge pull request 'feat: integrate core EMS logic, shared modules, and DB schema' (#10) from feature/ems-core-logic into main
Reviewed-on: #10
2026-07-08 17:01:48 +05:30
1b69e9f14c feat: integrate core EMS logic, shared modules, and DB schema 2026-07-08 17:09:20 +05:30
6187ec4afc Merge pull request 'Update AMS attendance logic and routes' (#9) from feature/ams-module into main
Reviewed-on: #9
2026-07-08 12:20:54 +05:30
52b2e40762 Merge pull request 'feat(ams,ems):added the summary endpoints for attendance report, fixed the organizational lookup endpoints of ems module' (#8) from feature/ams-module into main
Reviewed-on: #8
2026-07-08 12:18:38 +05:30
ceb8c97531 Merge pull request 'feat(ams): implement bulk processing engine and dynamic regularization review loop' (#7) from feature/ams-module into main
Reviewed-on: #7

Closes historical processing execution bottlenecks. Delivers robust bulk data calculations, structural fixes for database type alignment, and an enterprise admin approval architecture for managing attendance modifications.
2026-07-07 13:30:10 +05:30
3d1e24d641 Merge pull request 'feat(ams,ems): add ams service, timezone-safe processing engine, and dynamic employee seeding' (#6) from feature/ams-module into main
Reviewed-on: #6

Merge branch 'feature/ams-module' into main

Features and Fixes:
- Initialized the dynamic Attendance Management Service (AMS) module.
- Implemented a timezone-safe daily processing engine utilizing text-based database date extractions.
- Built a transactional, multi-table bulk-seeding API endpoint in the EMS module to parse and align flat employee payloads.
- Refactored the shared database connection infrastructure to dynamically yield generic MySQL pools for cross-service connectivity.
2026-07-07 11:30:08 +05:30
18 changed files with 2062 additions and 202 deletions

View File

@ -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,105 +180,146 @@ 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";
} }
} }
await amsConnection.execute( // Only update the ledger if an actual attendance record exists
`UPDATE processed_daily_attendance if (request.attendance_id) {
SET check_in = ?, await amsConnection.execute(
check_out = ?, `UPDATE processed_daily_attendance
worked_hours = ?, SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
final_status = ?, WHERE attendance_id = ?`,
check_in_status = ? [
WHERE attendance_id = ?`, request.requested_check_in,
[ request.requested_check_out,
request.requested_check_in, worked_hours,
request.requested_check_out, final_status,
worked_hours, check_in_status,
final_status, request.attendance_id,
check_in_status, ],
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();
} }

View File

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

View File

@ -1,11 +1,14 @@
import { getDbPool } from "../../shared/db.ts" import { getDbPool } from "../../shared/db.ts";
import { generateNextCode } from "../../shared/sequence.ts";
const pool = getDbPool("hrms_ems"); const pool = getDbPool("hrms_ems");
export const getEmployees = async (ctx: any) => { export const getEmployees = async (ctx: any) => {
try { try {
// FIX: Filter by ACTIVE contract to prevent duplicate rows for employees with past contracts
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT `SELECT
e.employee_id,
e.employee_code, e.employee_code,
p.first_name, p.first_name,
p.last_name, p.last_name,
@ -14,28 +17,27 @@ export const getEmployees = async (ctx: any) => {
c.work_email c.work_email
FROM employees e FROM employees e
JOIN partners p ON e.partner_id = p.partner_id JOIN partners p ON e.partner_id = p.partner_id
JOIN contracts c ON e.employee_id = c.employee_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 departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id JOIN job_positions j ON c.job_id = j.job_id
WHERE e.is_active = true` WHERE e.is_active = true`,
); );
ctx.response.body = { ctx.response.body = {
success: true, success: true,
count: (rows as any[]).length, count: (rows as any[]).length,
data: rows data: rows,
}; };
} catch (error) { } catch (error) {
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { ctx.response.body = {
success: false, success: false,
error: (error as Error).message error: (error as Error).message,
}; };
} }
}; };
export const createEmployee = async (ctx: any) => { export const createEmployee = async (ctx: any) => {
// 1. Extract the JSON payload from the request
const body = ctx.request.body; const body = ctx.request.body;
if (body.type() !== "json") { if (body.type() !== "json") {
ctx.response.status = 400; ctx.response.status = 400;
@ -44,64 +46,96 @@ export const createEmployee = async (ctx: any) => {
} }
const data = await body.json(); const data = await body.json();
let empCode = data.employeeCode;
if (!empCode || empCode.trim() === "") {
empCode = await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
}
// 2. Acquire a dedicated connection from the pool for our transaction
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
// 3. Start the transaction
await connection.beginTransaction(); await connection.beginTransaction();
// STEP A: Insert into Partners
const [partnerResult] = await connection.execute( const [partnerResult] = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) `INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone] [
data.firstName,
data.lastName,
data.dob,
data.gender,
data.personalEmail,
data.personalPhone,
],
); );
const partnerId = (partnerResult as any).insertId; const partnerId = (partnerResult as any).insertId;
// STEP B: Insert into Addresses
await connection.execute( await connection.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state) `INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[partnerId, data.address.type, data.address.doorNumber, data.address.landmark, data.address.line, data.address.pincode, data.address.district, data.address.state] [
partnerId,
data.address.type,
data.address.doorNumber,
data.address.landmark,
data.address.line,
data.address.pincode,
data.address.district,
data.address.state,
],
); );
// STEP C: Insert into Employees
const [employeeResult] = await connection.execute( const [employeeResult] = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, work_email, is_active) `INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
[data.employeeCode, partnerId, data.companyId, data.branchId, data.work_email, true] [empCode, partnerId, data.companyId, data.branchId, true],
); );
const employeeId = (employeeResult as any).insertId; const employeeId = (employeeResult as any).insertId;
// STEP D: Insert into Contracts
await connection.execute( 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, date_joining, probation_days, status, salary_structure_id, reporting_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[employeeId, data.departmentId, data.jobId, data.dateJoining, data.probationDays, 'ACTIVE', data.salaryStructureId] [
employeeId,
data.departmentId,
data.jobId,
data.work_email,
data.dateJoining,
data.probationDays,
"ACTIVE",
data.salaryStructureId,
data.reportingToId || null,
],
); );
// 4. Commit the transaction if all steps succeed
await connection.commit(); await connection.commit();
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
message: "Employee created successfully", message: "Employee created successfully",
employee_id: employeeId employee_id: employeeId,
employee_code: empCode,
}; };
} catch (error) { } catch (error) {
// 5. Rollback everything if any step fails
await connection.rollback(); 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.status = 500;
ctx.response.body = { ctx.response.body = {
success: false, success: false,
error: "Transaction failed: " + (error as Error).message error: "Transaction failed: " + errorMessage,
}; };
} finally { } finally {
// 6. Release the connection back to the pool
connection.release(); connection.release();
} }
}; };
@ -110,7 +144,8 @@ export const getEmployeeById = async (ctx: any) => {
const id = ctx.params.id; const id = ctx.params.id;
try { try {
const [rows] = await pool.query( // 1. Fetch core employee, partner, contract, and manager details
const [empRows] = await pool.query(
`SELECT `SELECT
e.employee_id, e.employee_id,
e.employee_code, e.employee_code,
@ -121,51 +156,83 @@ export const getEmployeeById = async (ctx: any) => {
p.gender, p.gender,
p.personal_email, p.personal_email,
p.personal_phone, p.personal_phone,
a.address_type,
a.door_number,
a.landmark,
a.address_line,
a.pincode,
a.district,
a.state,
c.work_email, c.work_email,
c.date_joining, c.date_joining,
c.probation_days, c.probation_days,
c.status AS contract_status, c.status AS contract_status,
d.name AS department, d.name AS department,
j.title AS designation 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 FROM employees e
JOIN partners p ON e.partner_id = p.partner_id JOIN partners p ON e.partner_id = p.partner_id
LEFT JOIN addresses a ON p.partner_id = a.partner_id JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
JOIN contracts c ON e.employee_id = c.employee_id
JOIN departments d ON c.department_id = d.department_id JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_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 = ?`, WHERE e.employee_id = ?`,
[id] [id],
); );
const data = rows as any[]; const empData = empRows as any[];
if (data.length === 0) { if (empData.length === 0) {
ctx.response.status = 404; ctx.response.status = 404;
ctx.response.body = { ctx.response.body = { success: false, message: "Employee not found" };
success: false,
message: "Employee not found"
};
return; 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.status = 200;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: data[0] 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) { } catch (error) {
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { ctx.response.body = { success: false, error: (error as Error).message };
success: false,
error: (error as Error).message
};
} }
}; };
@ -185,10 +252,9 @@ export const updateEmployee = async (ctx: any) => {
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
// 1. Fetch the partner_id linked to this employee
const [employeeRows] = await connection.execute( const [employeeRows] = await connection.execute(
`SELECT partner_id FROM employees WHERE employee_id = ?`, `SELECT partner_id FROM employees WHERE employee_id = ?`,
[id] [id],
); );
const employees = employeeRows as any[]; const employees = employeeRows as any[];
@ -200,15 +266,19 @@ export const updateEmployee = async (ctx: any) => {
const partnerId = employees[0].partner_id; const partnerId = employees[0].partner_id;
// 2. Update the Partners table (Personal Data)
await connection.execute( await connection.execute(
`UPDATE partners `UPDATE partners
SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ? SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ?
WHERE partner_id = ?`, WHERE partner_id = ?`,
[data.firstName, data.lastName, data.personalEmail, data.personalPhone, partnerId] [
data.firstName,
data.lastName,
data.personalEmail,
data.personalPhone,
partnerId,
],
); );
// 3. Update the Addresses table
await connection.execute( await connection.execute(
`UPDATE addresses `UPDATE addresses
SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ? SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
@ -221,8 +291,8 @@ export const updateEmployee = async (ctx: any) => {
data.address.district, data.address.district,
data.address.state, data.address.state,
partnerId, partnerId,
data.address.type data.address.type,
] ],
); );
await connection.commit(); await connection.commit();
@ -230,15 +300,14 @@ export const updateEmployee = async (ctx: any) => {
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
message: "Employee profile updated successfully" message: "Employee profile updated successfully",
}; };
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { ctx.response.body = {
success: false, success: false,
error: "Update failed: " + (error as Error).message error: "Update failed: " + (error as Error).message,
}; };
} finally { } finally {
connection.release(); connection.release();
@ -251,7 +320,7 @@ export const deleteEmployee = async (ctx: any) => {
try { try {
const [result] = await pool.execute( const [result] = await pool.execute(
`UPDATE employees SET is_active = false WHERE employee_id = ?`, `UPDATE employees SET is_active = false WHERE employee_id = ?`,
[id] [id],
); );
const updateResult = result as any; const updateResult = result as any;
@ -265,14 +334,13 @@ export const deleteEmployee = async (ctx: any) => {
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = { ctx.response.body = {
success: true, success: true,
message: "Employee account deactivated successfully" message: "Employee account deactivated successfully",
}; };
} catch (error) { } catch (error) {
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { ctx.response.body = {
success: false, success: false,
error: "Deactivation failed: " + (error as Error).message error: "Deactivation failed: " + (error as Error).message,
}; };
} }
}; };

View File

@ -1,4 +1,5 @@
import { getDbPool } from "../../shared/db.ts" import { getDbPool } from "../../shared/db.ts"
import {generateNextCode} from "../../shared/sequence.ts"
const pool = getDbPool("hrms_ems"); const pool = getDbPool("hrms_ems");
@ -37,7 +38,7 @@ export const getDepartments = async (ctx: any) => {
export const getJobs = async (ctx: any) => { export const getJobs = async (ctx: any) => {
try { try {
const [rows] = await pool.query(`SELECT job_id, title, company_id FROM job_positions`); const [rows] = await pool.query(`SELECT job_id, title, department_id FROM job_positions`);
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = { success: true, data: rows }; ctx.response.body = { success: true, data: rows };
} catch (error) { } catch (error) {
@ -45,3 +46,398 @@ export const getJobs = async (ctx: any) => {
ctx.response.body = { success: false, error: (error as Error).message }; 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 };
}
};

View File

@ -16,18 +16,16 @@ export const bulkSeedEmployees = async (ctx: Context) => {
let insertedCount = 0; let insertedCount = 0;
try { try {
// Start transaction to keep data clean
await connection.beginTransaction(); await connection.beginTransaction();
for (const item of body) { for (const item of body) {
const { emp_code, name, designation, department, email } = item; const { emp_code, name, designation, department, email } = item;
// Split first and last name if possible, fallback if singular name
const nameParts = name.trim().split(" "); const nameParts = name.trim().split(" ");
const firstName = nameParts[0] || "Employee"; const firstName = nameParts[0] || "Employee";
const lastName = nameParts.slice(1).join(" ") || "LNU"; const lastName = nameParts.slice(1).join(" ") || "LNU";
// 1. Insert into partners (identity layer) with fallback defaults // 1. Insert into partners
const [partnerResult]: any = await connection.execute( const [partnerResult]: any = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) `INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?) VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
@ -36,25 +34,25 @@ export const bulkSeedEmployees = async (ctx: Context) => {
); );
const partnerId = partnerResult.insertId; const partnerId = partnerResult.insertId;
// 2. Ensure the department exists, dynamically fetch or create its ID // 2. Ensure department exists (FIXED: Added branch_id to satisfy NOT NULL constraint)
const [deptResult]: any = await connection.execute( const [deptResult]: any = await connection.execute(
`INSERT INTO departments (company_id, name) `INSERT INTO departments (company_id, branch_id, name)
VALUES (1, ?) VALUES (1, 1, ?)
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`, ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
[department || "General"] [department || "General"]
); );
const departmentId = deptResult.insertId; const departmentId = deptResult.insertId;
// 3. Ensure the job position exists, dynamically fetch or create its ID // 3. Ensure job position exists (FIXED: Linked to department_id instead of company_id)
const [jobResult]: any = await connection.execute( const [jobResult]: any = await connection.execute(
`INSERT INTO job_positions (company_id, title) `INSERT INTO job_positions (department_id, title)
VALUES (1, ?) VALUES (?, ?)
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`, ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
[designation || "Trainee"] [departmentId, designation || "Trainee"]
); );
const jobId = jobResult.insertId; const jobId = jobResult.insertId;
// 4. Create the core Employee record mapping to the KENT code // 4. Create core Employee record
const [empResult]: any = await connection.execute( const [empResult]: any = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) `INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, 1, 1, TRUE) VALUES (?, ?, 1, 1, TRUE)
@ -63,7 +61,7 @@ export const bulkSeedEmployees = async (ctx: Context) => {
); );
const employeeId = empResult.insertId; const employeeId = empResult.insertId;
// 5. Establish the operational Contract record with the corporate work email // 5. Establish operational Contract
await connection.execute( await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id) `INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id)
VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100) VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100)
@ -74,7 +72,6 @@ export const bulkSeedEmployees = async (ctx: Context) => {
insertedCount++; insertedCount++;
} }
// Commit changes safely to EMS
await connection.commit(); await connection.commit();
ctx.response.status = 200; ctx.response.status = 200;
@ -85,10 +82,7 @@ export const bulkSeedEmployees = async (ctx: Context) => {
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
console.error("Seeding failed, changes rolled back:", error); console.error("Seeding failed, changes rolled back:", error);
// Fixed: Cast error to 'any' or fallback to a string to satisfy deno-ts(18046)
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage }; ctx.response.body = { success: false, error: errorMessage };
} finally { } finally {
@ -98,7 +92,6 @@ export const bulkSeedEmployees = async (ctx: Context) => {
export const bulkMapHierarchy = async (ctx: Context) => { export const bulkMapHierarchy = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : {}; const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
const { employeeHierarchy = [], departmentHierarchy = [] } = body; const { employeeHierarchy = [], departmentHierarchy = [] } = body;
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) { if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) {
@ -114,10 +107,9 @@ export const bulkMapHierarchy = async (ctx: Context) => {
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
// 1. Map Employees to their Managers // 1. Map Employees to their Managers (Contracts table)
for (const mapping of employeeHierarchy) { for (const mapping of employeeHierarchy) {
const { emp_code, manager_code } = mapping; const { emp_code, manager_code } = mapping;
if (!emp_code || !manager_code) continue; if (!emp_code || !manager_code) continue;
const [result]: any = await connection.execute( const [result]: any = await connection.execute(
@ -128,22 +120,21 @@ export const bulkMapHierarchy = async (ctx: Context) => {
WHERE e.employee_code = ? AND c.status = 'ACTIVE'`, WHERE e.employee_code = ? AND c.status = 'ACTIVE'`,
[manager_code, emp_code] [manager_code, emp_code]
); );
if (result.affectedRows > 0) contractsUpdated++; if (result.affectedRows > 0) contractsUpdated++;
} }
// 2. Map Departments to their Managers and Parent Departments // 2. Map Departments to Managers and Parent Departments
for (const dept of departmentHierarchy) { for (const dept of departmentHierarchy) {
const { department_name, manager_code, parent_department_name } = dept; const { department_name, manager_code, parent_department_name } = dept;
if (!department_name) continue; if (!department_name) continue;
// Update Department Manager // FIXED: Use department_managers junction table instead of departments.manager_id
if (manager_code) { if (manager_code) {
await connection.execute( await connection.execute(
`UPDATE departments d `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 = ? JOIN employees m ON m.employee_code = ?
SET d.manager_id = m.employee_id
WHERE d.name = ?`, WHERE d.name = ?`,
[manager_code, department_name] [manager_code, department_name]
); );
@ -168,15 +159,11 @@ export const bulkMapHierarchy = async (ctx: Context) => {
ctx.response.body = { ctx.response.body = {
success: true, success: true,
message: "Hierarchy mapping completed successfully.", message: "Hierarchy mapping completed successfully.",
metrics: { metrics: { contractsUpdated, departmentsProcessed: departmentsUpdated }
contractsUpdated,
departmentsProcessed: departmentsUpdated
}
}; };
} catch (error) { } catch (error) {
await connection.rollback(); await connection.rollback();
console.error("Hierarchy mapping failed:", error); console.error("Hierarchy mapping failed:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500; ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage }; ctx.response.body = { success: false, error: errorMessage };

View File

@ -7,10 +7,10 @@ import {
deleteEmployee deleteEmployee
} from "./controllers/employee.controller.ts"; } from "./controllers/employee.controller.ts";
import { import {
getCompanies, getCompanies, createCompany, updateCompany, deleteCompany,
getBranches, getBranches, createBranch, updateBranch, deleteBranch,
getDepartments, getDepartments, createDepartment, updateDepartment, deleteDepartment,
getJobs getJobs, createJob, updateJob, deleteJob
} from "./controllers/lookup.controller.ts"; } from "./controllers/lookup.controller.ts";
import { import {
getEmployeeContracts, getEmployeeContracts,
@ -64,15 +64,27 @@ router.post("/api/v1/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN,
// Fetch legal entities and parent group structures. // Fetch legal entities and parent group structures.
router.get("/api/v1/companies", requireAuth, getCompanies); 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. // Fetch structural branch offices and physical locations.
router.get("/api/v1/branches", requireAuth, getBranches); 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. // List corporate departments and organizational chart reporting lines.
router.get("/api/v1/departments", requireAuth, getDepartments); 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. // List company designations and employment titles.
router.get("/api/v1/jobs", requireAuth, getJobs); 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 // SYSTEM & MIGRATION DOMAIN

View File

@ -15,7 +15,7 @@ CREATE TABLE branches (
branch_id INT AUTO_INCREMENT PRIMARY KEY, branch_id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL, company_id INT NOT NULL,
branch_name VARCHAR(100) NOT NULL, branch_name VARCHAR(100) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE, -- e.g., 'BLR-HQ' code VARCHAR(20) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -51,26 +51,27 @@ CREATE TABLE addresses (
CREATE TABLE departments ( CREATE TABLE departments (
department_id INT AUTO_INCREMENT PRIMARY KEY, department_id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL, company_id INT NOT NULL,
branch_id INT NOT NULL,
name VARCHAR(50) NOT NULL, name VARCHAR(50) NOT NULL,
parent_id INT NULL, parent_id INT NULL,
manager_id INT NULL, -- Logical Reference to employee_id
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE, FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. Functional Designations -- 6. Functional Designations
CREATE TABLE job_positions ( CREATE TABLE job_positions (
job_id INT AUTO_INCREMENT PRIMARY KEY, job_id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL, department_id INT NOT NULL,
title VARCHAR(50) NOT NULL, title VARCHAR(50) NOT NULL,
description TEXT NULL, description TEXT NULL,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. Master Employee Mapping Engine -- 7. Master Employee Mapping Engine
CREATE TABLE employees ( CREATE TABLE employees (
employee_id INT AUTO_INCREMENT PRIMARY KEY, employee_id INT AUTO_INCREMENT PRIMARY KEY,
employee_code VARCHAR(20) NOT NULL UNIQUE, -- Matches your Excel Base Column employee_code VARCHAR(20) NOT NULL UNIQUE,
partner_id INT NOT NULL, partner_id INT NOT NULL,
company_id INT NOT NULL, company_id INT NOT NULL,
branch_id INT NOT NULL, branch_id INT NOT NULL,
@ -87,13 +88,30 @@ CREATE TABLE contracts (
employee_id INT NOT NULL, employee_id INT NOT NULL,
department_id INT NOT NULL, department_id INT NOT NULL,
job_id INT NOT NULL, job_id INT NOT NULL,
reporting_to_id INT NULL, -- Logical Reference to employee_id reporting_to_id INT NULL,
work_email VARCHAR(100) NOT NULL UNIQUE, work_email VARCHAR(100) NOT NULL UNIQUE,
date_joining DATE NOT NULL, date_joining DATE NOT NULL,
probation_days INT DEFAULT 90, probation_days INT DEFAULT 90,
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT', status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
salary_structure_id INT NOT NULL, -- Logical Reference to PMS Microservice database salary_structure_id INT NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE, FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT, FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 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,
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;
-- 10. System Sequences
CREATE TABLE system_sequences (
sequence_id VARCHAR(50) PRIMARY KEY,
prefix VARCHAR(10) NOT NULL,
current_value INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

108
init-db/04-init-lms.sql Normal file
View 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;

View 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" };
}
};

View 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" };
}
};

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

View 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" };
}
};

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

65
shared/auth.ts Normal file
View File

@ -0,0 +1,65 @@
// shared/auth.ts
import { Context, Next } from "@oak/oak";
import { getDbPool } from "./db.ts";
export enum AppRole {
SUPER_ADMIN = "SUPER_ADMIN",
ADMIN = "ADMIN",
MANAGER = "MANAGER",
EMPLOYEE = "EMPLOYEE",
}
// 1. Authentication Middleware
export const requireAuth = async (ctx: Context, next: Next) => {
const isAuthenticated = true;
if (!isAuthenticated) {
ctx.response.status = 401;
ctx.response.body = { success: false, message: "Missing or invalid token" };
return;
}
// 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 = {
employee_id: mockEmployeeId,
role: mockRole,
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;
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();
};
};

116
shared/calendar.service.ts Normal file
View 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;
}

50
shared/sequence.ts Normal file
View File

@ -0,0 +1,50 @@
// shared/sequence.ts
import { getDbPool } from "./db.ts";
const pool = getDbPool("hrms_ems");
/**
* Generates a sequential code for a given entity.
* @param sequenceId Unique identifier for the counter (e.g., 'EMPLOYEE', 'BRANCH_BEN')
* @param prefix The string to prepend to the number (e.g., 'CLRI', 'BEN-')
* @param padding How many digits the number should be (e.g., 3 -> '001')
*/
export const generateNextCode = async (
sequenceId: string,
prefix: string,
padding: number = 3
): Promise<string> => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// 1. Insert a new counter if it doesn't exist, OR increment the existing one atomically
await connection.execute(
`INSERT INTO system_sequences (sequence_id, prefix, current_value)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE current_value = current_value + 1, prefix = ?`,
[sequenceId, prefix, prefix]
);
// 2. Safely retrieve the updated value
const [rows] = await connection.execute(
`SELECT prefix, current_value FROM system_sequences WHERE sequence_id = ?`,
[sequenceId]
);
await connection.commit();
const data = (rows as any[])[0];
// 3. Format the result (e.g., prefix "CLRI" + value 1 + padding 3 = "CLRI001")
const paddedValue = data.current_value.toString().padStart(padding, '0');
return `${data.prefix}${paddedValue}`;
} catch (error) {
await connection.rollback();
throw new Error(`Failed to generate sequence for ${sequenceId}: ${(error as Error).message}`);
} finally {
connection.release();
}
};