Compare commits

..

2 Commits

8 changed files with 557 additions and 741 deletions

View File

@ -4,50 +4,33 @@ const pool = getDbPool("hrms_ems");
export const getEmployeeContracts = async (ctx: any) => {
const employeeId = ctx.params.id;
try {
// Fetches combined history of terms and assignments
const [rows] = await pool.query(
`SELECT
c.contract_id,
c.work_email,
c.date_joining,
c.probation_days,
c.status,
c.salary_structure_id,
d.name AS department,
j.title AS designation
FROM contracts c
JOIN employees e ON c.employee_id = e.employee_id
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
WHERE c.employee_id = ?
ORDER BY c.date_joining DESC`,
et.term_id, et.work_email, et.date_joining, et.probation_days, et.status, et.salary_structure_id,
ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS designation
FROM employment_terms et
JOIN employee_assignments ea ON et.employee_id = ea.employee_id
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE et.employee_id = ?
ORDER BY et.date_joining DESC, ea.effective_from DESC`,
[employeeId]
);
ctx.response.status = 200;
ctx.response.body = {
success: true,
count: (rows as any[]).length,
data: rows
};
ctx.response.body = { success: true, count: (rows as any[]).length, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: (error as Error).message
};
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const createContract = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "Invalid JSON body" };
return;
}
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();
@ -55,46 +38,31 @@ export const createContract = async (ctx: any) => {
try {
await connection.beginTransaction();
// 1. Expire any currently active contracts for this specific employee
// 1. Expire old assignment
await connection.execute(
`UPDATE contracts SET status = 'EXPIRED' WHERE employee_id = ? AND status = 'ACTIVE'`,
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
[data.employeeId]
);
// 2. Insert the brand new active contract
// 2. Insert new assignment (Promotion/Transfer)
const [result] = await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, reporting_to_id, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.employeeId,
data.departmentId,
data.jobId,
data.workEmail, // <-- Added
data.reportingToId, // <-- Added
data.dateJoining,
data.probationDays,
'ACTIVE',
data.salaryStructureId
]
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
);
await connection.commit();
// Optional: If compensation changes, you would expire old employment_terms and insert new ones here.
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "New contract executed successfully. Previous contracts expired.",
contract_id: (result as any).insertId
message: "New assignment executed successfully. Previous assignments expired.",
assignment_id: (result as any).insertId
};
} catch (error) {
await connection.rollback();
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Transaction failed: " + (error as Error).message
};
} finally {
connection.release();
}
ctx.response.body = { success: false, error: "Transaction failed: " + (error as Error).message };
} finally { connection.release(); }
};

View File

@ -0,0 +1,66 @@
// dashboard.controller.ts
import { getDbPool } from "../../shared/db.ts";
const pool = getDbPool("hrms_ems");
export const getDashboardMetrics = async (ctx: any) => {
try {
const params = ctx.request.url.searchParams;
const companyId = params.get('company_id');
const branchId = params.get('branch_id');
const departmentId = params.get('department_id');
// Build dynamic WHERE clauses for each entity type based on the filters provided
let companyFilter = " WHERE 1=1";
let branchFilter = " WHERE 1=1";
let deptFilter = " WHERE 1=1";
let jobFilter = " WHERE 1=1";
let empFilter = " WHERE e.is_active = TRUE";
const valuesC: any[] = [];
const valuesB: any[] = [];
const valuesD: any[] = [];
const valuesJ: any[] = [];
const valuesE: any[] = [];
if (companyId) {
branchFilter += " AND company_id = ?"; valuesB.push(companyId);
deptFilter += " AND company_id = ?"; valuesD.push(companyId);
jobFilter += " AND d.company_id = ?"; valuesJ.push(companyId);
empFilter += " AND e.company_id = ?"; valuesE.push(companyId);
}
if (branchId) {
deptFilter += " AND branch_id = ?"; valuesD.push(branchId);
jobFilter += " AND d.branch_id = ?"; valuesJ.push(branchId);
empFilter += " AND e.branch_id = ?"; valuesE.push(branchId);
}
if (departmentId) {
jobFilter += " AND j.department_id = ?"; valuesJ.push(departmentId);
empFilter += " AND ea.department_id = ?"; valuesE.push(departmentId);
}
// Execute all 5 count queries in parallel for maximum speed
const [cRows]: any = await pool.query(`SELECT COUNT(*) as count FROM companies ${companyFilter}`, valuesC);
const [bRows]: any = await pool.query(`SELECT COUNT(*) as count FROM branches ${branchFilter}`, valuesB);
const [dRows]: any = await pool.query(`SELECT COUNT(*) as count FROM departments ${deptFilter}`, valuesD);
const [jRows]: any = await pool.query(`SELECT COUNT(*) as count FROM job_positions j JOIN departments d ON j.department_id = d.department_id ${jobFilter}`, valuesJ);
const [eRows]: any = await pool.query(`SELECT COUNT(*) as count FROM employees e JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE ${empFilter}`, valuesE);
ctx.response.body = {
success: true,
data: {
total_companies: cRows[0].count,
total_branches: bRows[0].count,
total_departments: dRows[0].count,
total_jobs: jRows[0].count,
total_employees: eRows[0].count
}
};
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};

View File

@ -5,229 +5,158 @@ const pool = getDbPool("hrms_ems");
export const getEmployees = async (ctx: any) => {
try {
// FIX: Filter by ACTIVE contract to prevent duplicate rows for employees with past contracts
const [rows] = await pool.query(
`SELECT
e.employee_id,
e.employee_code,
p.first_name,
p.last_name,
d.name AS department,
j.title AS designation,
c.work_email
const params = ctx.request.url.searchParams;
let query = `SELECT e.employee_id, e.employee_code, e.is_active,
CONCAT(p.first_name, ' ', p.last_name) AS full_name,
et.work_email, -- <-- ADDED WORK EMAIL HERE
j.title AS job_name, d.name AS department_name,
b.branch_name, c.name AS company_name
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
WHERE e.is_active = true`,
);
JOIN companies c ON e.company_id = c.company_id
JOIN branches b ON e.branch_id = b.branch_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE' -- <-- ADDED JOIN
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
WHERE 1=1`;
const values: any[] = [];
ctx.response.body = {
success: true,
count: (rows as any[]).length,
data: rows,
};
if (params.get('company_id')) { query += ` AND e.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND e.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('department_id')) { query += ` AND ea.department_id = ?`; values.push(params.get('department_id')); }
if (params.get('is_active')) { query += ` AND e.is_active = ?`; values.push(params.get('is_active') === 'true'); }
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: (rows as any[]).length, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: (error as Error).message,
};
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const createEmployee = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "Invalid JSON body" };
return;
}
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
let empCode = data.employeeCode;
if (!empCode || empCode.trim() === "") {
empCode = await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
}
let empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [partnerResult] = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, ?, ?, ?, ?)`,
[
data.firstName,
data.lastName,
data.dob,
data.gender,
data.personalEmail,
data.personalPhone,
],
const [partnerResult]: any = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) VALUES (?, ?, ?, ?, ?, ?)`,
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone]
);
const partnerId = (partnerResult as any).insertId;
const partnerId = partnerResult.insertId;
const [empResult]: any = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) VALUES (?, ?, ?, ?, ?)`,
[empCode, partnerId, data.companyId, data.branchId, true]
);
const employeeId = empResult.insertId;
await connection.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
partnerId,
data.address.type,
data.address.doorNumber,
data.address.landmark,
data.address.line,
data.address.pincode,
data.address.district,
data.address.state,
],
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, ?, ?, 'ACTIVE', ?)`,
[employeeId, data.work_email, data.dateJoining, data.probationDays, data.salaryStructureId]
);
const [employeeResult] = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, ?, ?, ?)`,
[empCode, partnerId, data.companyId, data.branchId, true],
);
const employeeId = (employeeResult as any).insertId;
await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, probation_days, status, salary_structure_id, reporting_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
employeeId,
data.departmentId,
data.jobId,
data.work_email,
data.dateJoining,
data.probationDays,
"ACTIVE",
data.salaryStructureId,
data.reportingToId || null,
],
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, ?, 'HIRE', ?, TRUE)`,
[employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.dateJoining]
);
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Employee created successfully",
employee_id: employeeId,
employee_code: empCode,
};
ctx.response.body = { success: true, message: "Employee created", employee_id: employeeId, employee_code: empCode };
} catch (error) {
await connection.rollback();
let errorMessage = (error as Error).message;
if (errorMessage.includes("Duplicate entry")) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error:
"A unique constraint failed. The Employee Code, Personal Email, or Phone number already exists.",
};
ctx.response.body = { success: false, error: "A unique constraint failed. Employee Code, Email, or Phone already exists." };
return;
}
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Transaction failed: " + errorMessage,
ctx.response.body = { success: false, error: "Transaction failed: " + errorMessage };
} finally { connection.release(); }
};
} finally {
connection.release();
export const getEmployeeHistory = async (ctx: any) => {
const id = ctx.params.id;
try {
const [rows] = await pool.query(
`SELECT ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
d.name AS department, j.title AS job_title,
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name
FROM employee_assignments ea
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE ea.employee_id = ?
ORDER BY ea.effective_from DESC`,
[id]
);
ctx.response.body = { success: true, history: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
export const getEmployeeById = async (ctx: any) => {
const id = ctx.params.id;
try {
// 1. Fetch core employee, partner, contract, and manager details
// FIX: Querying the new split tables (employment_terms & employee_assignments)
const [empRows] = await pool.query(
`SELECT
e.employee_id,
e.employee_code,
e.is_active,
p.first_name,
p.last_name,
p.dob,
p.gender,
p.personal_email,
p.personal_phone,
c.work_email,
c.date_joining,
c.probation_days,
c.status AS contract_status,
d.name AS department,
d.department_id,
j.title AS designation,
j.job_id,
mgr_p.first_name AS manager_first_name,
mgr_p.last_name AS manager_last_name,
mgr_e.employee_code AS manager_employee_code
e.employee_id, e.employee_code, e.is_active,
p.first_name, p.last_name, p.dob, p.gender, p.personal_email, p.personal_phone,
et.work_email, et.date_joining, et.probation_days, et.status AS contract_status, et.salary_structure_id,
d.name AS department, d.department_id, j.title AS designation, j.job_id,
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name, mgr_e.employee_code AS manager_employee_code
FROM employees e
JOIN partners p ON e.partner_id = p.partner_id
JOIN contracts c ON e.employee_id = c.employee_id AND c.status = 'ACTIVE'
JOIN departments d ON c.department_id = d.department_id
JOIN job_positions j ON c.job_id = j.job_id
LEFT JOIN employees mgr_e ON c.reporting_to_id = mgr_e.employee_id
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
JOIN departments d ON ea.department_id = d.department_id
JOIN job_positions j ON ea.job_id = j.job_id
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
WHERE e.employee_id = ?`,
[id],
[id]
);
const empData = empRows as any[];
if (empData.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Employee not found" };
return;
}
if (empData.length === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Employee not found" }; return; }
const employee = empData[0];
// 2. Fetch all addresses for this partner separately to return as an array
const [addrRows] = await pool.query(
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
FROM addresses
WHERE partner_id = (SELECT partner_id FROM employees WHERE employee_id = ?)`,
[id],
[id]
);
// 3. Combine into a single structured JSON response
ctx.response.status = 200;
ctx.response.body = {
success: true,
data: {
employee_id: employee.employee_id,
employee_code: employee.employee_code,
is_active: employee.is_active,
first_name: employee.first_name,
last_name: employee.last_name,
dob: employee.dob,
gender: employee.gender,
personal_email: employee.personal_email,
personal_phone: employee.personal_phone,
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,
work_email: employee.work_email, date_joining: employee.date_joining, probation_days: employee.probation_days,
status: employee.contract_status, salary_structure_id: employee.salary_structure_id,
department: employee.department, department_id: employee.department_id, designation: employee.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)
addresses: addrRows,
},
};
} catch (error) {
@ -252,6 +181,7 @@ export const updateEmployee = async (ctx: any) => {
try {
await connection.beginTransaction();
// 1. Check if employee exists and get partner_id
const [employeeRows] = await connection.execute(
`SELECT partner_id FROM employees WHERE employee_id = ?`,
[id],
@ -261,41 +191,30 @@ export const updateEmployee = async (ctx: any) => {
if (employees.length === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Employee not found" };
await connection.rollback();
return;
}
const partnerId = employees[0].partner_id;
// 2. Update Partners table (Added dob and gender)
await connection.execute(
`UPDATE partners
SET first_name = ?, last_name = ?, personal_email = ?, personal_phone = ?
SET first_name = ?, last_name = ?, dob = ?, gender = ?, personal_email = ?, personal_phone = ?
WHERE partner_id = ?`,
[
data.firstName,
data.lastName,
data.dob,
data.gender,
data.personalEmail,
data.personalPhone,
partnerId,
],
);
// await connection.execute(
// `UPDATE addresses
// SET door_number = ?, landmark = ?, address_line = ?, pincode = ?, district = ?, state = ?
// WHERE partner_id = ? AND address_type = ?`,
// [
// data.address.doorNumber,
// data.address.landmark,
// data.address.line,
// data.address.pincode,
// data.address.district,
// data.address.state,
// partnerId,
// data.address.type,
// ],
// );
// Upsert Address (Fixes the missing address bug)
// 3. Upsert Address
if (data.address) {
await connection.execute(
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@ -317,6 +236,34 @@ export const updateEmployee = async (ctx: any) => {
data.address.state,
]
);
}
// 4. Update Employment Terms (Work Email)
if (data.work_email) {
await connection.execute(
`UPDATE employment_terms
SET work_email = ?
WHERE employee_id = ? AND status = 'ACTIVE'`,
[data.work_email, id]
);
}
// 5. Update Employee Assignments (Department, Job, Manager)
// We update the current active assignment directly.
// (If this is a formal promotion/transfer, the frontend should use the POST /contracts endpoint instead to preserve history)
if (data.departmentId || data.jobId || data.reportingToId) {
await connection.execute(
`UPDATE employee_assignments
SET department_id = ?, job_id = ?, reporting_to_id = ?
WHERE employee_id = ? AND is_current = TRUE`,
[
data.departmentId,
data.jobId,
data.reportingToId || null,
id
]
);
}
await connection.commit();
@ -327,10 +274,22 @@ export const updateEmployee = async (ctx: any) => {
};
} catch (error) {
await connection.rollback();
// Gracefully catch unique constraint violations on Email/Phone
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = {
success: false,
error: "Update failed: The Personal Email, Phone, or Work Email already belongs to another employee."
};
return;
}
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Update failed: " + (error as Error).message,
error: "Update failed: " + err.message,
};
} finally {
connection.release();
@ -339,31 +298,13 @@ export const updateEmployee = async (ctx: any) => {
export const deleteEmployee = async (ctx: any) => {
const id = ctx.params.id;
try {
const [result] = await pool.execute(
`UPDATE employees SET is_active = false WHERE employee_id = ?`,
[id],
);
const updateResult = result as any;
if (updateResult.affectedRows === 0) {
ctx.response.status = 404;
ctx.response.body = { success: false, message: "Employee not found" };
return;
}
const [result] = await pool.execute(`UPDATE employees SET is_active = false WHERE employee_id = ?`, [id]);
if ((result as any).affectedRows === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Employee not found" }; return; }
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Employee account deactivated successfully",
};
ctx.response.body = { success: true, message: "Employee account deactivated successfully" };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = {
success: false,
error: "Deactivation failed: " + (error as Error).message,
};
ctx.response.body = { success: false, error: "Deactivation failed: " + (error as Error).message };
}
};

View File

@ -3,441 +3,341 @@ import {generateNextCode} from "../../shared/sequence.ts"
const pool = getDbPool("hrms_ems");
// Dynamic helper to build WHERE clauses
const buildFilter = (params: URLSearchParams, allowedFilters: string[]) => {
let clause = " WHERE 1=1";
const values: any[] = [];
allowedFilters.forEach(filter => {
const value = params.get(filter);
if (value) { clause += ` AND ${filter} = ?`; values.push(value); }
});
return { clause, values };
};
export const getCompanies = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT company_id, name FROM companies`);
ctx.response.status = 200;
const { clause, values } = buildFilter(ctx.request.url.searchParams, ['is_active']);
const [rows] = await pool.query(`SELECT company_id, company_code, name, is_active FROM companies ${clause}`, values);
ctx.response.body = { success: true, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getBranches = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT branch_id, branch_name,code, company_id FROM branches`);
ctx.response.status = 200;
const params = ctx.request.url.searchParams;
let query = `SELECT b.branch_id, b.code, b.branch_name, b.is_active, c.company_code, c.name AS company_name
FROM branches b JOIN companies c ON b.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
if (params.get('company_id')) { query += ` AND b.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('is_active')) { query += ` AND b.is_active = ?`; values.push(params.get('is_active') === 'true'); }
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getDepartments = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT department_id, name, parent_id FROM departments`);
ctx.response.status = 200;
ctx.response.body = { success: true, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
const params = ctx.request.url.searchParams;
let query = `SELECT d.department_id, d.department_code, d.name AS department_name, d.is_active,
b.branch_name, c.name AS company_name,
(SELECT GROUP_CONCAT(CONCAT(p.first_name, ' ', p.last_name) SEPARATOR ', ')
FROM department_managers dm
JOIN employees e ON dm.employee_id = e.employee_id
JOIN partners p ON e.partner_id = p.partner_id
WHERE dm.department_id = d.department_id AND dm.is_current = TRUE) AS managers
FROM departments d JOIN branches b ON d.branch_id = b.branch_id JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
// Filtering
if (params.get('company_id')) { query += ` AND d.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND d.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('is_active')) { query += ` AND d.is_active = ?`; values.push(params.get('is_active') === 'true'); }
// Sorting (Safe mapping to prevent SQL injection)
const sortByParam = params.get('sort_by') || 'department_name';
const sortOrderParam = params.get('sort_order') === 'desc' ? 'DESC' : 'ASC';
const validSortColumns: Record<string, string> = {
'department_name': 'd.name', 'department_code': 'd.department_code', 'company_name': 'c.name', 'branch_name': 'b.branch_name'
};
const sortColumn = validSortColumns[sortByParam] || 'd.name';
query += ` ORDER BY ${sortColumn} ${sortOrderParam}`;
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: rows.length, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
export const getJobs = async (ctx: any) => {
try {
const [rows] = await pool.query(`SELECT job_id, title, department_id FROM job_positions`);
ctx.response.status = 200;
ctx.response.body = { success: true, data: rows };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
}
};
const params = ctx.request.url.searchParams;
let query = `SELECT j.job_id, j.job_code, j.title AS job_name, j.is_active,
d.name AS department_name, b.branch_name, c.name AS company_name
FROM job_positions j JOIN departments d ON j.department_id = d.department_id
JOIN branches b ON d.branch_id = b.branch_id JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
const values: any[] = [];
// Filtering
if (params.get('company_id')) { query += ` AND d.company_id = ?`; values.push(params.get('company_id')); }
if (params.get('branch_id')) { query += ` AND d.branch_id = ?`; values.push(params.get('branch_id')); }
if (params.get('department_id')) { query += ` AND j.department_id = ?`; values.push(params.get('department_id')); }
// Sorting (Safe mapping)
const sortByParam = params.get('sort_by') || 'job_name';
const sortOrderParam = params.get('sort_order') === 'desc' ? 'DESC' : 'ASC';
const validSortColumns: Record<string, string> = {
'job_name': 'j.title', 'job_code': 'j.job_code', 'department_name': 'd.name'
};
const sortColumn = validSortColumns[sortByParam] || 'j.title';
query += ` ORDER BY ${sortColumn} ${sortOrderParam}`;
const [rows] = await pool.query(query, values);
ctx.response.body = { success: true, count: rows.length, data: rows };
} catch (error) { ctx.response.status = 500; ctx.response.body = { success: false, error: (error as Error).message }; }
};
// ==========================================
// COMPANIES MANAGEMENT
// ==========================================
export const createCompany = async (ctx: any) => {
const body = ctx.request.body;
if (body.type() !== "json") {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "Invalid JSON body" };
return;
}
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
const data = await body.json();
let companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
try {
const [result] = await pool.execute(
`INSERT INTO companies (name, parent_id) VALUES (?, ?)`,
[data.name, data.parentId || null]
`INSERT INTO companies (company_code, name, parent_id, is_active) VALUES (?, ?, ?, ?)`,
[companyCode, data.name, data.parentId || null, data.isActive ?? true]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId };
ctx.response.body = { success: true, message: "Company created", company_id: (result as any).insertId, company_code: companyCode };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated company code '${companyCode}' already exists.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.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;
}
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]
`UPDATE companies SET name = ?, parent_id = ?, is_active = ? WHERE company_id = ?`,
[data.name, data.parentId || null, data.isActive, 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 };
}
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" };
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 };
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;
}
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]
`INSERT INTO branches (company_id, branch_name, code, is_active) VALUES (?, ?, ?, ?)`,
[data.companyId, data.branchName, branchCode, data.isActive ?? true]
);
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Branch created successfully",
branch_id: (result as any).insertId,
code: 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;
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.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 };
ctx.response.status = 500; ctx.response.body = { success: false, error: err.message };
}
};
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 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]
`UPDATE branches SET company_id = ?, branch_name = ?, code = ?, is_active = ? WHERE branch_id = ?`,
[data.companyId, data.branchName, data.code, data.isActive, 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 };
}
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" };
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 };
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;
}
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; }
if (!data.companyId || !data.branchId || !data.name) {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "companyId, branchId, and name are required." };
return;
}
let deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
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]
`INSERT INTO departments (company_id, branch_id, department_code, name, parent_id, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
[data.companyId, data.branchId, deptCode, data.name, data.parentId ?? null, data.isActive ?? true]
);
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 (?, ?)`,
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)`,
[departmentId, data.managerId]
);
}
await connection.commit();
ctx.response.status = 201;
ctx.response.body = {
success: true,
message: "Department created",
department_id: departmentId
};
ctx.response.status = 201; ctx.response.body = { success: true, message: "Department created", department_id: departmentId, department_code: deptCode };
} catch (error) {
await connection.rollback();
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
} finally {
connection.release();
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated department code '${deptCode}' already exists. Please sync your database sequences.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.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 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]
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ?, is_active = ? WHERE department_id = ?`,
[data.companyId ?? null, data.branchId ?? null, data.name ?? null, data.parentId ?? null, data.isActive, 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;
}
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(`UPDATE department_managers SET is_current = FALSE, removed_at = NOW() WHERE department_id = ? AND is_current = TRUE`, [id]);
await connection.execute(
`INSERT INTO department_managers (department_id, employee_id) VALUES (?, ?)`,
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)
ON DUPLICATE KEY UPDATE is_current = TRUE, removed_at = NULL`,
[id, data.managerId]
);
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = { success: true, message: "Department updated successfully" };
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();
}
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" };
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 };
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete department. There are active assignments tied to it.";
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;
}
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; }
if (!data.departmentId || !data.title) {
ctx.response.status = 400;
ctx.response.body = { success: false, error: "departmentId and title are required fields." };
return;
}
let jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
try {
const [result] = await pool.execute(
`INSERT INTO job_positions (department_id, title, description) VALUES (?, ?, ?)`,
[data.departmentId, data.title, data.description ?? null]
`INSERT INTO job_positions (department_id, job_code, title, description, is_active) VALUES (?, ?, ?, ?, ?)`,
[data.departmentId, jobCode, data.title, data.description ?? null, data.isActive ?? true]
);
ctx.response.status = 201;
ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId };
ctx.response.status = 201; ctx.response.body = { success: true, message: "Job created", job_id: (result as any).insertId, job_code: jobCode };
} catch (error) {
ctx.response.status = 500;
ctx.response.body = { success: false, error: (error as Error).message };
const err = error as any;
if (err.code === 'ER_DUP_ENTRY' || err.message.includes('Duplicate entry')) {
ctx.response.status = 409;
ctx.response.body = { success: false, error: `The generated job code '${jobCode}' already exists. Please sync your database sequences.` };
return;
}
ctx.response.status = 500; ctx.response.body = { success: false, error: err.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 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]
`UPDATE job_positions SET department_id = ?, title = ?, description = ?, is_active = ? WHERE job_id = ?`,
[data.departmentId ?? null, data.title ?? null, data.description ?? null, data.isActive, 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 };
}
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" };
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 };
if (errorMessage.includes("foreign key constraint fails")) errorMessage = "Cannot delete job position. It is currently linked to active employee assignments.";
ctx.response.status = 409; ctx.response.body = { success: false, error: errorMessage };
}
};

View File

@ -5,12 +5,7 @@ const emsPool = getDbPool("hrms_ems");
export const bulkSeedEmployees = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : [];
if (!Array.isArray(body) || body.length === 0) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Provide an array of employee logs." };
return;
}
if (!Array.isArray(body) || body.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide an array of employee logs." }; return; }
const connection = await emsPool.getConnection();
let insertedCount = 0;
@ -20,12 +15,11 @@ export const bulkSeedEmployees = async (ctx: Context) => {
for (const item of body) {
const { emp_code, name, designation, department, email } = item;
const nameParts = name.trim().split(" ");
const firstName = nameParts[0] || "Employee";
const lastName = nameParts.slice(1).join(" ") || "LNU";
// 1. Insert into partners
// 1. Partner
const [partnerResult]: any = await connection.execute(
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
@ -34,25 +28,25 @@ export const bulkSeedEmployees = async (ctx: Context) => {
);
const partnerId = partnerResult.insertId;
// 2. Ensure department exists (FIXED: Added branch_id to satisfy NOT NULL constraint)
// 2. Department
const [deptResult]: any = await connection.execute(
`INSERT INTO departments (company_id, branch_id, name)
VALUES (1, 1, ?)
`INSERT INTO departments (company_id, branch_id, department_code, name)
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
[department || "General"]
[department || "General", department || "General"]
);
const departmentId = deptResult.insertId;
// 3. Ensure job position exists (FIXED: Linked to department_id instead of company_id)
// 3. Job
const [jobResult]: any = await connection.execute(
`INSERT INTO job_positions (department_id, title)
VALUES (?, ?)
`INSERT INTO job_positions (department_id, job_code, title)
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
[departmentId, designation || "Trainee"]
[departmentId, designation || "Trainee", designation || "Trainee"]
);
const jobId = jobResult.insertId;
// 4. Create core Employee record
// 4. Employee
const [empResult]: any = await connection.execute(
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
VALUES (?, ?, 1, 1, TRUE)
@ -61,66 +55,63 @@ export const bulkSeedEmployees = async (ctx: Context) => {
);
const employeeId = empResult.insertId;
// 5. Establish operational Contract
// 5. Terms & Assignments (Replaces old contracts insert)
await connection.execute(
`INSERT INTO contracts (employee_id, department_id, job_id, work_email, date_joining, status, salary_structure_id)
VALUES (?, ?, ?, ?, '2026-01-01', 'ACTIVE', 100)
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
[employeeId, departmentId, jobId, email]
[employeeId, email]
);
await connection.execute(
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
VALUES (?, ?, ?, NULL, 'HIRE', '2026-01-01', TRUE)
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id), job_id = VALUES(job_id)`,
[employeeId, departmentId, jobId]
);
insertedCount++;
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: `Successfully seeded/aligned ${insertedCount} normalized employee profiles.`,
};
ctx.response.body = { success: true, message: `Successfully seeded/aligned ${insertedCount} normalized employee profiles.` };
} catch (error) {
await connection.rollback();
console.error("Seeding failed, changes rolled back:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage };
} finally {
connection.release();
}
} finally { connection.release(); }
};
export const bulkMapHierarchy = async (ctx: Context) => {
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) {
ctx.response.status = 400;
ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." };
return;
}
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." }; return; }
const connection = await emsPool.getConnection();
let contractsUpdated = 0;
let assignmentsUpdated = 0;
let departmentsUpdated = 0;
try {
await connection.beginTransaction();
// 1. Map Employees to their Managers (Contracts table)
// 1. Map Employees to their Managers (Updating active assignments)
for (const mapping of employeeHierarchy) {
const { emp_code, manager_code } = mapping;
if (!emp_code || !manager_code) continue;
const [result]: any = await connection.execute(
`UPDATE contracts c
JOIN employees e ON c.employee_id = e.employee_id
`UPDATE employee_assignments ea
JOIN employees e ON ea.employee_id = e.employee_id
JOIN employees m ON m.employee_code = ?
SET c.reporting_to_id = m.employee_id
WHERE e.employee_code = ? AND c.status = 'ACTIVE'`,
SET ea.reporting_to_id = m.employee_id
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
[manager_code, emp_code]
);
if (result.affectedRows > 0) contractsUpdated++;
if (result.affectedRows > 0) assignmentsUpdated++;
}
// 2. Map Departments to Managers and Parent Departments
@ -128,11 +119,10 @@ export const bulkMapHierarchy = async (ctx: Context) => {
const { department_name, manager_code, parent_department_name } = dept;
if (!department_name) continue;
// FIXED: Use department_managers junction table instead of departments.manager_id
if (manager_code) {
await connection.execute(
`INSERT IGNORE INTO department_managers (department_id, employee_id)
SELECT d.department_id, m.employee_id
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
SELECT d.department_id, m.employee_id, TRUE
FROM departments d
JOIN employees m ON m.employee_code = ?
WHERE d.name = ?`,
@ -140,7 +130,6 @@ export const bulkMapHierarchy = async (ctx: Context) => {
);
}
// Update Parent Department Hierarchy
if (parent_department_name) {
await connection.execute(
`UPDATE departments d
@ -154,20 +143,13 @@ export const bulkMapHierarchy = async (ctx: Context) => {
}
await connection.commit();
ctx.response.status = 200;
ctx.response.body = {
success: true,
message: "Hierarchy mapping completed successfully.",
metrics: { contractsUpdated, departmentsProcessed: departmentsUpdated }
};
ctx.response.body = { success: true, message: "Hierarchy mapping completed successfully.", metrics: { assignmentsUpdated, departmentsProcessed: departmentsUpdated } };
} catch (error) {
await connection.rollback();
console.error("Hierarchy mapping failed:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
ctx.response.status = 500;
ctx.response.body = { success: false, error: errorMessage };
} finally {
connection.release();
}
} finally { connection.release(); }
};

View File

@ -1,10 +1,7 @@
import { Router } from "@oak/oak";
import {getDashboardMetrics} from "./controllers/dashboard.controller.ts"
import {
getEmployees,
createEmployee,
getEmployeeById,
updateEmployee,
deleteEmployee
getEmployees, createEmployee, getEmployeeById, updateEmployee, deleteEmployee, getEmployeeHistory
} from "./controllers/employee.controller.ts";
import {
getCompanies, createCompany, updateCompany, deleteCompany,
@ -13,88 +10,54 @@ import {
getJobs, createJob, updateJob, deleteJob
} from "./controllers/lookup.controller.ts";
import {
getEmployeeContracts,
createContract
getEmployeeContracts, createContract
} from "./controllers/contract.controller.ts";
import {
bulkSeedEmployees,
bulkMapHierarchy
bulkSeedEmployees, bulkMapHierarchy
} from "./controllers/system.controller.ts";
// Shared authentication and authorization middleware
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
const router = new Router();
// ============================================================================
// EMPLOYEE CORE DOMAIN
// Manages the corporate identity (employees) and personal data (partners).
// ============================================================================
// EMPLOYEE CORE
router.get("/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
router.get("/employees/:id", requireAuth, getEmployeeById);
router.get("/employees/:id/history", requireAuth, getEmployeeHistory);
router.post("/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
router.put("/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
router.delete("/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
// Retrieve a list of all active employees. Visible to management and administrators.
router.get("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN, AppRole.MANAGER]), getEmployees);
// CONTRACT & ASSIGNMENT DOMAIN (Promotions/Transfers)
router.get("/employees/:id/contracts", requireAuth, getEmployeeContracts);
router.post("/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
// Retrieve a single employee's comprehensive profile (identity, address, contract). Accessible to any authenticated user.
router.get("/api/v1/employees/:id", requireAuth, getEmployeeById);
// Create a new employee profile and initial contract. Restricted to administrators.
router.post("/api/v1/employees", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createEmployee);
// Update an existing employee's personal or address data. Restricted to administrators.
router.put("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateEmployee);
// Soft-delete (deactivate) an employee account. Restricted to administrators.
router.delete("/api/v1/employees/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteEmployee);
// ============================================================================
// CONTRACT & ASSIGNMENT DOMAIN
// Manages operational assignments, reporting hierarchies, and job roles.
// ============================================================================
// Retrieve the contract history for an individual employee.
router.get("/api/v1/employees/:id/contracts", requireAuth, getEmployeeContracts);
// Expire current active contracts and execute a new employment agreement.
router.post("/api/v1/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
// ============================================================================
// ORGANIZATIONAL LOOKUP DOMAIN
// Read-only reference endpoints for the frontend UI to populate select options.
// ============================================================================
router.get("/companies", requireAuth, getCompanies);
router.post("/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
router.put("/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
router.delete("/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
// Fetch legal entities and parent group structures.
router.get("/api/v1/companies", requireAuth, getCompanies);
router.post("/api/v1/companies", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createCompany);
router.put("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateCompany);
router.delete("/api/v1/companies/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteCompany);
router.get("/branches", requireAuth, getBranches);
router.post("/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
router.put("/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
router.delete("/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
// Fetch structural branch offices and physical locations.
router.get("/api/v1/branches", requireAuth, getBranches);
router.post("/api/v1/branches", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createBranch);
router.put("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateBranch);
router.delete("/api/v1/branches/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteBranch);
router.get("/departments", requireAuth, getDepartments);
router.post("/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
router.put("/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
router.delete("/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
// List corporate departments and organizational chart reporting lines.
router.get("/api/v1/departments", requireAuth, getDepartments);
router.post("/api/v1/departments", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createDepartment);
router.put("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateDepartment);
router.delete("/api/v1/departments/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteDepartment);
router.get("/jobs", requireAuth, getJobs);
router.post("/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
router.put("/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
router.delete("/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
// List company designations and employment titles.
router.get("/api/v1/jobs", requireAuth, getJobs);
router.post("/api/v1/jobs", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createJob);
router.put("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), updateJob);
router.delete("/api/v1/jobs/:id", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), deleteJob);
// DASHBOARD METRICS
// Example: GET /dashboard/metrics?company_id=1&branch_id=2
router.get("/dashboard/metrics", requireAuth, getDashboardMetrics);
// ============================================================================
// SYSTEM & MIGRATION DOMAIN
// High-risk administrative endpoints for bulk data execution.
// ============================================================================
// Process raw bulk data to seed initial employee/partner structures into the DB.
router.post("/api/v1/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
// Execute mapping script to establish reporting_to_id and manager_id links.
router.post("/api/v1/system/map-hierarchy", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkMapHierarchy);
router.post("/system/seed-employees", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkSeedEmployees);
router.post("/system/map-hierarchy", requireAuth, requireRole([AppRole.SUPER_ADMIN]), bulkMapHierarchy);
export default router;

View File

@ -4,8 +4,10 @@ USE hrms_ems;
-- 1. Legal Entities
CREATE TABLE companies (
company_id INT AUTO_INCREMENT PRIMARY KEY,
company_code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
parent_id INT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES companies(company_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -16,6 +18,7 @@ CREATE TABLE branches (
company_id INT NOT NULL,
branch_name VARCHAR(100) NOT NULL,
code VARCHAR(20) NOT NULL UNIQUE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -44,6 +47,7 @@ CREATE TABLE addresses (
pincode VARCHAR(10) NOT NULL,
district VARCHAR(50) NOT NULL,
state VARCHAR(50) NOT NULL,
UNIQUE KEY uq_partner_address_type (partner_id, address_type),
FOREIGN KEY (partner_id) REFERENCES partners(partner_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -52,8 +56,10 @@ CREATE TABLE departments (
department_id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
branch_id INT NOT NULL,
department_code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
parent_id INT NULL,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (company_id) REFERENCES companies(company_id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES departments(department_id) ON DELETE SET NULL
@ -63,8 +69,10 @@ CREATE TABLE departments (
CREATE TABLE job_positions (
job_id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
job_code VARCHAR(20) NOT NULL UNIQUE,
title VARCHAR(50) NOT NULL,
description TEXT NULL,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@ -82,34 +90,61 @@ CREATE TABLE employees (
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. Legal & Operational Contracts
CREATE TABLE contracts (
contract_id INT AUTO_INCREMENT PRIMARY KEY,
-- 8. Legal & Compensation Terms (Changes rarely)
CREATE TABLE employment_terms (
term_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
work_email VARCHAR(100) NOT NULL,
date_joining DATE NOT NULL,
date_ended DATE NULL,
probation_days INT DEFAULT 90,
salary_structure_id INT NOT NULL,
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Organizational Assignments (Changes on promotion/transfer)
CREATE TABLE employee_assignments (
assignment_id INT AUTO_INCREMENT PRIMARY KEY,
employee_id INT NOT NULL,
department_id INT NOT NULL,
job_id INT NOT NULL,
reporting_to_id INT NULL,
work_email VARCHAR(100) NOT NULL UNIQUE,
date_joining DATE NOT NULL,
probation_days INT DEFAULT 90,
status ENUM('DRAFT', 'ACTIVE', 'EXPIRED', 'TERMINATED') DEFAULT 'DRAFT',
salary_structure_id INT NOT NULL,
change_reason ENUM('HIRE', 'TRANSFER', 'PROMOTION', 'MANAGER_CHANGE', 'REORG') NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE NULL,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE RESTRICT,
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT
FOREIGN KEY (job_id) REFERENCES job_positions(job_id) ON DELETE RESTRICT,
FOREIGN KEY (reporting_to_id) REFERENCES employees(employee_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Department Managers (Junction Table)
-- 10. Department Managers (Junction Table with History)
CREATE TABLE department_managers (
department_id INT NOT NULL,
employee_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_current BOOLEAN NOT NULL DEFAULT TRUE,
removed_at TIMESTAMP NULL,
PRIMARY KEY (department_id, employee_id),
FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE,
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 10. System Sequences
-- 11. Branch Admins (For AMS/LMS routing)
CREATE TABLE branch_admins (
employee_id INT NOT NULL,
branch_id INT NOT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (employee_id, branch_id),
FOREIGN KEY (employee_id) REFERENCES employees(employee_id) ON DELETE CASCADE,
FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 12. System Sequences
CREATE TABLE system_sequences (
sequence_id VARCHAR(50) PRIMARY KEY,
prefix VARCHAR(10) NOT NULL,

View File

@ -97,45 +97,6 @@ export const getLeaveHistory = async (ctx: Context) => {
}
};
/**
* 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
*/