Compare commits
No commits in common. "c21bbca78e945ed11a57789cc1b22f5754ae8076" and "bf85053ba43cc92b7f77c4e37204581e0afe179c" have entirely different histories.
c21bbca78e
...
bf85053ba4
25
.env
25
.env
@ -1,25 +0,0 @@
|
|||||||
# Gateway
|
|
||||||
GATEWAY_PORT=8000
|
|
||||||
|
|
||||||
# Microservice Ports
|
|
||||||
EMS_PORT=8001
|
|
||||||
AMS_PORT=8002
|
|
||||||
LMS_PORT=8003
|
|
||||||
|
|
||||||
# Internal Service URLs (Used for service-to-service HTTP calls)
|
|
||||||
EMS_URL=http://localhost:8001
|
|
||||||
AMS_URL=http://localhost:8002
|
|
||||||
LMS_URL=http://localhost:8003
|
|
||||||
|
|
||||||
# Internal API Token (Prevents direct access bypassing the Gateway)
|
|
||||||
INTERNAL_SERVICE_TOKEN=super-secret-dev-token-123
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_USER=admin
|
|
||||||
DB_PASSWORD=admin123
|
|
||||||
|
|
||||||
# Database Ports (Local Dev)
|
|
||||||
EMS_DB_PORT=3306
|
|
||||||
AMS_DB_PORT=3307
|
|
||||||
LMS_DB_PORT=3308
|
|
||||||
18
Dockerfile
18
Dockerfile
@ -1,18 +0,0 @@
|
|||||||
# Use the official Deno image (alpine for smaller size)
|
|
||||||
FROM denoland/deno:latest
|
|
||||||
|
|
||||||
# Set the working directory inside the container
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy the entire monorepo into the container
|
|
||||||
# This includes deno.json, packages/core, and all services
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Cache the dependencies for all services so they start instantly
|
|
||||||
RUN deno cache services/gateway/main.ts \
|
|
||||||
services/ems/main.ts \
|
|
||||||
services/ams/main.ts \
|
|
||||||
services/lms/main.ts
|
|
||||||
|
|
||||||
# The default command (docker-compose will override this for each service)
|
|
||||||
CMD ["run", "-A", "services/gateway/main.ts"]
|
|
||||||
519
ams-service/controllers/attendance.controller.ts
Normal file
519
ams-service/controllers/attendance.controller.ts
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
|
||||||
|
const amsDb = getDbPool("hrms_ams");
|
||||||
|
const emsDb = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch top 20 raw biometric records for debugging/auditing
|
||||||
|
*/
|
||||||
|
export const getRawLogs = async (ctx: Context) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await amsDb.execute(
|
||||||
|
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DB Error:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, message: "Database query failed" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to generate an array of dates between two boundaries
|
||||||
|
const getDaysArray = (start: string, end: string): string[] => {
|
||||||
|
const arr: string[] = [];
|
||||||
|
const dt = new Date(start);
|
||||||
|
const endDt = new Date(end);
|
||||||
|
while (dt <= endDt) {
|
||||||
|
arr.push(new Date(dt).toISOString().split('T')[0]);
|
||||||
|
dt.setDate(dt.getDate() + 1);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processDailyAttendance = async (ctx: Context) => {
|
||||||
|
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
||||||
|
let datesToProcess: string[] = [];
|
||||||
|
|
||||||
|
// 1. Determine date execution scope
|
||||||
|
if (body.work_date) {
|
||||||
|
datesToProcess.push(body.work_date);
|
||||||
|
} else if (body.start_date && body.end_date) {
|
||||||
|
datesToProcess = getDaysArray(body.start_date, body.end_date);
|
||||||
|
} else {
|
||||||
|
// Safely pull unique dates using direct string formatting in SQL to avoid Deno timezone shifting bugs
|
||||||
|
const [dateRows]: any = await amsDb.execute(
|
||||||
|
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
|
||||||
|
);
|
||||||
|
// Explicitly guarantee we extract the exact date string format without hitting Deno Date constructor limits
|
||||||
|
datesToProcess = dateRows.map((r: any) => {
|
||||||
|
if (r.raw_date instanceof Date) {
|
||||||
|
// Fallback if the driver parses it into a Date object despite format rules
|
||||||
|
const offset = r.raw_date.getTimezoneOffset();
|
||||||
|
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
|
||||||
|
return localDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
return String(r.raw_date); // Clean 'YYYY-MM-DD' string representation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datesToProcess.length === 0) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "No dates found to process." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amsConnection = await amsDb.getConnection();
|
||||||
|
let totalProcessedRecords = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await amsConnection.beginTransaction();
|
||||||
|
|
||||||
|
// Loop through every single date sequentially
|
||||||
|
for (const targetDate of datesToProcess) {
|
||||||
|
// 2. Fetch raw logs specifically grouped for this targetDate
|
||||||
|
const [rawLogs]: any = await amsConnection.execute(
|
||||||
|
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
|
||||||
|
FROM attendance_raw_logs
|
||||||
|
WHERE DATE(attendance_time) = ?
|
||||||
|
ORDER BY employee_code, attendance_time ASC`,
|
||||||
|
[targetDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawLogs.length === 0) continue;
|
||||||
|
|
||||||
|
// Group raw logs by employee_code for this specific day
|
||||||
|
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
|
||||||
|
if (!acc[log.employee_code]) acc[log.employee_code] = [];
|
||||||
|
acc[log.employee_code].push(log.att_time);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// 3. Process calculations per employee for this targetDate
|
||||||
|
for (const empCode in groupedLogs) {
|
||||||
|
// Look up employee metadata from EMS
|
||||||
|
const [empRows]: any = await emsDb.execute(
|
||||||
|
`SELECT employee_id, is_active FROM employees WHERE employee_code = ?`,
|
||||||
|
[empCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empRows.length === 0 || !empRows[0].is_active) {
|
||||||
|
console.log(`[AMS Worker] Skipping unmapped or inactive code: ${empCode}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeId = empRows[0].employee_id;
|
||||||
|
const punches = groupedLogs[empCode];
|
||||||
|
|
||||||
|
// Fetch shift details (Defaulting to shift_id 1 for evaluation)
|
||||||
|
const [shiftRows]: any = await amsConnection.execute(
|
||||||
|
`SELECT start_time, grace_period_minutes FROM shifts WHERE shift_id = 1`
|
||||||
|
);
|
||||||
|
const shift = shiftRows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
|
||||||
|
|
||||||
|
let check_in = null;
|
||||||
|
let check_out = null;
|
||||||
|
let worked_hours = 0.0;
|
||||||
|
let check_in_status = "ON_TIME";
|
||||||
|
let final_status = "ABSENT";
|
||||||
|
|
||||||
|
if (punches.length >= 2) {
|
||||||
|
check_in = punches[0];
|
||||||
|
check_out = punches[punches.length - 1];
|
||||||
|
|
||||||
|
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
|
||||||
|
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
|
||||||
|
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
|
||||||
|
|
||||||
|
const rawCheckInTimeStr = check_in.split(' ')[1];
|
||||||
|
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
||||||
|
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
||||||
|
|
||||||
|
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
|
||||||
|
check_in_status = "LATE";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
||||||
|
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
||||||
|
else final_status = "ABSENT";
|
||||||
|
|
||||||
|
} else if (punches.length === 1) {
|
||||||
|
check_in = punches[0];
|
||||||
|
check_in_status = "MISPUNCH";
|
||||||
|
final_status = "MISPUNCH";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upsert calculations atomically into the processed daily ledger
|
||||||
|
await amsConnection.execute(
|
||||||
|
`INSERT INTO processed_daily_attendance
|
||||||
|
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
|
||||||
|
VALUES (?, 1, 1, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
check_in = VALUES(check_in),
|
||||||
|
check_out = VALUES(check_out),
|
||||||
|
worked_hours = VALUES(worked_hours),
|
||||||
|
check_in_status = VALUES(check_in_status),
|
||||||
|
final_status = VALUES(final_status)`,
|
||||||
|
[employeeId, targetDate, check_in, check_out, worked_hours, check_in_status, final_status]
|
||||||
|
);
|
||||||
|
|
||||||
|
totalProcessedRecords++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await amsConnection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully processed ${totalProcessedRecords} logs across ${datesToProcess.length} days.`
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await amsConnection.rollback();
|
||||||
|
console.error("Bulk processing failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
} finally {
|
||||||
|
amsConnection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEmployeeSummary = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const employeeId = params.get("employee_id");
|
||||||
|
const startDate = params.get("start_date");
|
||||||
|
const endDate = params.get("end_date");
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!employeeId || !startDate || !endDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Missing required query parameters. Expected: employee_id, start_date, and end_date."
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the individual breakdown rows for the period
|
||||||
|
const [rows]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours,
|
||||||
|
check_in_status,
|
||||||
|
final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date DESC`,
|
||||||
|
[employeeId, startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamic metrics aggregation block
|
||||||
|
const summaryMetrics = rows.reduce(
|
||||||
|
(acc: any, row: any) => {
|
||||||
|
acc.total_days_tracked++;
|
||||||
|
acc.total_hours_worked += Number(row.worked_hours || 0);
|
||||||
|
|
||||||
|
// Count up categorical operational states
|
||||||
|
if (row.final_status === "FULL_DAY") acc.full_days++;
|
||||||
|
else if (row.final_status === "HALF_DAY") acc.half_days++;
|
||||||
|
else if (row.final_status === "ABSENT") acc.absences++;
|
||||||
|
else if (row.final_status === "MISPUNCH") acc.mispunches++;
|
||||||
|
|
||||||
|
if (row.check_in_status === "LATE") acc.late_arrivals++;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
total_days_tracked: 0,
|
||||||
|
total_hours_worked: 0,
|
||||||
|
full_days: 0,
|
||||||
|
half_days: 0,
|
||||||
|
absences: 0,
|
||||||
|
mispunches: 0,
|
||||||
|
late_arrivals: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format final working duration to a neat 2-decimal scale
|
||||||
|
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
metrics: summaryMetrics,
|
||||||
|
history: rows
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch employee summary:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const targetDate = params.get("date");
|
||||||
|
|
||||||
|
if (!targetDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing required 'date' query parameter (YYYY-MM-DD)." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Grab all calculated attendance states for this date from AMS
|
||||||
|
const [attendanceRows]: any = await amsDb.execute(
|
||||||
|
`SELECT employee_id, attendance_id,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date = ?`,
|
||||||
|
[targetDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Fetch active corporate names and metadata mappings from EMS
|
||||||
|
const [employeeRows]: any = await emsDb.execute(
|
||||||
|
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
WHERE e.is_active = TRUE`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map the attendance records by employee_id for O(1) matching speed
|
||||||
|
const attendanceMap = attendanceRows.reduce((acc: any, row: any) => {
|
||||||
|
acc[row.employee_id] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Summary metrics counter block for the Admin dashboard cards
|
||||||
|
const summary = {
|
||||||
|
total_active_workforce: employeeRows.length,
|
||||||
|
present: 0,
|
||||||
|
late: 0,
|
||||||
|
mispunches: 0,
|
||||||
|
absent: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Seamlessly blend employee profiles with attendance states
|
||||||
|
const roster = employeeRows.map((emp: any) => {
|
||||||
|
const attendance = attendanceMap[emp.employee_id];
|
||||||
|
|
||||||
|
let status = "ABSENT";
|
||||||
|
let check_in = null;
|
||||||
|
let check_out = null;
|
||||||
|
let worked_hours = "0.00";
|
||||||
|
let punctuality = "N/A";
|
||||||
|
let attendance_id = null;
|
||||||
|
|
||||||
|
if (attendance) {
|
||||||
|
status = attendance.final_status;
|
||||||
|
check_in = attendance.check_in;
|
||||||
|
check_out = attendance.check_out;
|
||||||
|
worked_hours = attendance.worked_hours;
|
||||||
|
punctuality = attendance.check_in_status;
|
||||||
|
attendance_id = attendance.attendance_id;
|
||||||
|
|
||||||
|
// Process summary counter evaluations
|
||||||
|
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
|
||||||
|
if (status === "MISPUNCH") summary.mispunches++;
|
||||||
|
if (punctuality === "LATE") summary.late++;
|
||||||
|
} else {
|
||||||
|
summary.absent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: emp.employee_id,
|
||||||
|
employee_code: emp.employee_code,
|
||||||
|
full_name: `${emp.first_name} ${emp.last_name}`,
|
||||||
|
attendance_id,
|
||||||
|
status,
|
||||||
|
check_in,
|
||||||
|
check_out,
|
||||||
|
worked_hours,
|
||||||
|
punctuality
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
date: targetDate,
|
||||||
|
summary,
|
||||||
|
roster
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate daily admin report:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminRangeReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const fromDate = params.get("from_date");
|
||||||
|
const toDate = params.get("to_date");
|
||||||
|
const branchId = params.get("branch_id"); // Optional filter
|
||||||
|
const departmentId = params.get("department_id"); // Optional filter
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing required date boundaries: from_date and to_date (YYYY-MM-DD)." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Dynamically build the EMS query based on branch or department selection
|
||||||
|
let emsQuery = `
|
||||||
|
SELECT e.employee_id, e.employee_code, p.first_name, p.last_name, b.branch_name, d.name as department_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN partners p ON e.partner_id = p.partner_id
|
||||||
|
INNER JOIN branches b ON e.branch_id = b.branch_id
|
||||||
|
INNER JOIN contracts c ON e.employee_id = c.employee_id
|
||||||
|
INNER JOIN departments d ON c.department_id = d.department_id
|
||||||
|
WHERE e.is_active = TRUE AND c.status = 'ACTIVE'
|
||||||
|
`;
|
||||||
|
const emsParams: any[] = [];
|
||||||
|
|
||||||
|
if (branchId) {
|
||||||
|
emsQuery += ` AND e.branch_id = ?`;
|
||||||
|
emsParams.push(branchId);
|
||||||
|
}
|
||||||
|
if (departmentId) {
|
||||||
|
emsQuery += ` AND c.department_id = ?`;
|
||||||
|
emsParams.push(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [employees]: any = await emsDb.execute(emsQuery, emsParams);
|
||||||
|
|
||||||
|
if (employees.length === 0) {
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, summary: {}, report: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeeIds = employees.map((e: any) => e.employee_id);
|
||||||
|
|
||||||
|
// 2. Query AMS for all processed ledger entries matching these exact employees in the date range
|
||||||
|
// Using a parameterized string hack for the IN clause since Deno MySQL handles arrays explicitly
|
||||||
|
const placeholders = employeeIds.map(() => "?").join(",");
|
||||||
|
const [attendanceRows]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
employee_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[fromDate, toDate, ...employeeIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group logs by employee_id for structural assembly
|
||||||
|
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
|
||||||
|
if (!acc[row.employee_id]) acc[row.employee_id] = [];
|
||||||
|
acc[row.employee_id].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Overall global counters for the Admin's upper summary ribbon card metrics
|
||||||
|
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
|
||||||
|
|
||||||
|
const report = employees.map((emp: any) => {
|
||||||
|
const logs = attendanceGrouped[emp.employee_id] || [];
|
||||||
|
|
||||||
|
// Calculate individual metrics summary for this specific employee across the range
|
||||||
|
const individualMetrics = logs.reduce((acc: any, log: any) => {
|
||||||
|
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
|
||||||
|
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
|
||||||
|
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
|
||||||
|
|
||||||
|
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
|
||||||
|
return acc;
|
||||||
|
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emp,
|
||||||
|
range_metrics: individualMetrics,
|
||||||
|
attendance_history: logs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Admin range report compilation failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSingleEmployeeRangeReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const employeeId = params.get("employee_id");
|
||||||
|
const fromDate = params.get("from_date");
|
||||||
|
const toDate = params.get("to_date");
|
||||||
|
|
||||||
|
if (!employeeId || !fromDate || !toDate) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing query parameters. Required: employee_id, from_date, to_date." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Grab profile data
|
||||||
|
const [empProfile]: any = await emsDb.execute(
|
||||||
|
`SELECT e.employee_id, e.employee_code, p.first_name, p.last_name
|
||||||
|
FROM employees e INNER JOIN partners p ON e.partner_id = p.partner_id WHERE e.employee_id = ?`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empProfile.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee profile not found." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab attendance logs for date-to-date query window
|
||||||
|
const [logs]: any = await amsDb.execute(
|
||||||
|
`SELECT
|
||||||
|
attendance_id,
|
||||||
|
DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
||||||
|
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
||||||
|
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
||||||
|
worked_hours, check_in_status, final_status
|
||||||
|
FROM processed_daily_attendance
|
||||||
|
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
||||||
|
ORDER BY work_date ASC`,
|
||||||
|
[employeeId, fromDate, toDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
employee: empProfile[0],
|
||||||
|
range: { from: fromDate, to: toDate },
|
||||||
|
total_days: logs.length,
|
||||||
|
history: logs
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Single employee range query failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
326
ams-service/controllers/regularization.controller.ts
Normal file
326
ams-service/controllers/regularization.controller.ts
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
import { AppRole } from "../../shared/auth.ts"; // Importing roles for security checks
|
||||||
|
|
||||||
|
const amsDb = getDbPool("hrms_ams");
|
||||||
|
const emsDb = getDbPool("hrms_ems"); // Used for cross-database joins
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EMPLOYEE: Create a new regularization request
|
||||||
|
*/
|
||||||
|
export const createRegularizationRequest = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing request body." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const {
|
||||||
|
attendance_id,
|
||||||
|
employee_id,
|
||||||
|
regularization_type,
|
||||||
|
target_date,
|
||||||
|
requested_check_in,
|
||||||
|
requested_check_out,
|
||||||
|
reason,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!employee_id || !reason || !regularization_type || !target_date) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Missing required fields: employee_id, target_date, regularization_type, and reason.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch employee's branch_id from EMS so AMS knows which branch this request belongs to
|
||||||
|
const [empRows]: any = await emsDb.execute(
|
||||||
|
`SELECT branch_id FROM employees WHERE employee_id = ?`,
|
||||||
|
[employee_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (empRows.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Employee not found in EMS.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch_id = empRows[0].branch_id;
|
||||||
|
|
||||||
|
// Insert into AMS with the cached branch_id
|
||||||
|
await amsDb.execute(
|
||||||
|
`INSERT INTO attendance_regularizations
|
||||||
|
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
|
||||||
|
[
|
||||||
|
attendance_id || null,
|
||||||
|
employee_id,
|
||||||
|
branch_id,
|
||||||
|
regularization_type,
|
||||||
|
target_date,
|
||||||
|
requested_check_in || null,
|
||||||
|
requested_check_out || null,
|
||||||
|
reason,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Regularization request submitted successfully and is pending approval.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to submit regularization:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN: Fetch pending regularizations based on their assigned branches
|
||||||
|
*/
|
||||||
|
export const getPendingRegularizations = async (ctx: Context) => {
|
||||||
|
const user = ctx.state.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
r.regularization_id,
|
||||||
|
r.employee_id,
|
||||||
|
r.target_date,
|
||||||
|
r.regularization_type,
|
||||||
|
r.requested_check_in,
|
||||||
|
r.requested_check_out,
|
||||||
|
r.reason,
|
||||||
|
e.employee_code,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name,
|
||||||
|
b.branch_name
|
||||||
|
FROM attendance_regularizations r
|
||||||
|
INNER JOIN hrms_ems.employees e ON r.employee_id = e.employee_id
|
||||||
|
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
||||||
|
INNER JOIN hrms_ems.branches b ON r.branch_id = b.branch_id
|
||||||
|
WHERE r.status = 'PENDING'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
// If the user is an ADMIN (not SUPER_ADMIN), restrict to their managed branches
|
||||||
|
if (user.role !== AppRole.SUPER_ADMIN) {
|
||||||
|
const branches = user.managed_branches;
|
||||||
|
if (!branches || branches.length === 0) {
|
||||||
|
// If an admin has no branches assigned, return empty list
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placeholders = branches.map(() => "?").join(",");
|
||||||
|
query += ` AND r.branch_id IN (${placeholders})`;
|
||||||
|
params.push(...branches);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY r.target_date DESC`;
|
||||||
|
|
||||||
|
const [rows]: any = await amsDb.execute(query, params);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
count: rows.length,
|
||||||
|
data: rows,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch pending regularizations:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN: Review (Approve/Reject) a regularization request
|
||||||
|
*/
|
||||||
|
export const reviewRegularizationRequest = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing request body." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const user = ctx.state.user; // The admin reviewing it
|
||||||
|
const { regularization_id, action } = body; // action can be 'APPROVED' or 'REJECTED'
|
||||||
|
|
||||||
|
if (!regularization_id || !["APPROVED", "REJECTED"].includes(action)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid payload. Required: regularization_id, action.",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amsConnection = await amsDb.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await amsConnection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Fetch the regularization request details
|
||||||
|
const [requestRows]: any = await amsConnection.execute(
|
||||||
|
`SELECT regularization_id, attendance_id, branch_id, requested_check_in, requested_check_out, status
|
||||||
|
FROM attendance_regularizations WHERE regularization_id = ?`,
|
||||||
|
[regularization_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestRows.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Regularization request not found.",
|
||||||
|
};
|
||||||
|
await amsConnection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = requestRows[0];
|
||||||
|
|
||||||
|
// SECURITY CHECK: Ensure the admin actually manages this branch
|
||||||
|
if (
|
||||||
|
user.role !== AppRole.SUPER_ADMIN &&
|
||||||
|
!user.managed_branches.includes(request.branch_id)
|
||||||
|
) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "Access Denied: You do not manage this branch.",
|
||||||
|
};
|
||||||
|
await amsConnection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== "PENDING") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
message: "This request has already been processed.",
|
||||||
|
};
|
||||||
|
await amsConnection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update the regularization request record status and reviewer ID
|
||||||
|
await amsConnection.execute(
|
||||||
|
`UPDATE attendance_regularizations
|
||||||
|
SET status = ?, reviewed_by_id = ?
|
||||||
|
WHERE regularization_id = ?`,
|
||||||
|
[action, user.employee_id, regularization_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. If APPROVED, dynamically recalculate and overwrite the target day's processed ledger row
|
||||||
|
if (action === "APPROVED") {
|
||||||
|
let worked_hours = 0.0;
|
||||||
|
let final_status = "FULL_DAY";
|
||||||
|
let check_in_status = "ON_TIME";
|
||||||
|
|
||||||
|
// Fetch shift details (Fallback pattern)
|
||||||
|
const [empRows]: any = await amsConnection.execute(
|
||||||
|
`SELECT company_id, branch_id FROM hrms_ems.employees WHERE employee_id = ?`,
|
||||||
|
[requestRows[0].employee_id],
|
||||||
|
);
|
||||||
|
const emp = empRows[0];
|
||||||
|
|
||||||
|
const [shiftRows]: any = await amsConnection.execute(
|
||||||
|
`SELECT start_time, grace_period_minutes FROM shifts
|
||||||
|
WHERE (branch_id = ? OR branch_id IS NULL) AND company_id = ?
|
||||||
|
ORDER BY branch_id IS NULL ASC LIMIT 1`,
|
||||||
|
[emp.branch_id, emp.company_id],
|
||||||
|
);
|
||||||
|
const shift = shiftRows[0] ||
|
||||||
|
{ start_time: "10:00:00", grace_period_minutes: 10 };
|
||||||
|
|
||||||
|
if (request.requested_check_in && request.requested_check_out) {
|
||||||
|
const checkInMs = new Date(request.requested_check_in.replace(" ", "T"))
|
||||||
|
.getTime();
|
||||||
|
const checkOutMs = new Date(
|
||||||
|
request.requested_check_out.replace(" ", "T"),
|
||||||
|
).getTime();
|
||||||
|
|
||||||
|
// Handle potential night shift edge case
|
||||||
|
const effectiveCheckOutMs = checkOutMs < checkInMs
|
||||||
|
? checkOutMs + (24 * 60 * 60 * 1000)
|
||||||
|
: checkOutMs;
|
||||||
|
|
||||||
|
worked_hours = Math.round(
|
||||||
|
((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100,
|
||||||
|
) / 100;
|
||||||
|
|
||||||
|
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
||||||
|
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
||||||
|
else final_status = "ABSENT";
|
||||||
|
|
||||||
|
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
|
||||||
|
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(
|
||||||
|
Number,
|
||||||
|
);
|
||||||
|
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(
|
||||||
|
Number,
|
||||||
|
);
|
||||||
|
|
||||||
|
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
||||||
|
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 +
|
||||||
|
(shift.grace_period_minutes * 60);
|
||||||
|
|
||||||
|
if (punchTotalSeconds > shiftCutoffSeconds) {
|
||||||
|
check_in_status = "LATE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update the ledger if an actual attendance record exists
|
||||||
|
if (request.attendance_id) {
|
||||||
|
await amsConnection.execute(
|
||||||
|
`UPDATE processed_daily_attendance
|
||||||
|
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
|
||||||
|
WHERE attendance_id = ?`,
|
||||||
|
[
|
||||||
|
request.requested_check_in,
|
||||||
|
request.requested_check_out,
|
||||||
|
worked_hours,
|
||||||
|
final_status,
|
||||||
|
check_in_status,
|
||||||
|
request.attendance_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await amsConnection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: `Request has been successfully ${action.toLowerCase()}.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await amsConnection.rollback();
|
||||||
|
console.error("Admin review action failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
amsConnection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
37
ams-service/routes.ts
Normal file
37
ams-service/routes.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
getRawLogs,
|
||||||
|
processDailyAttendance,
|
||||||
|
getEmployeeSummary,
|
||||||
|
getDailyReport,
|
||||||
|
getAdminRangeReport,
|
||||||
|
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
||||||
|
import {createRegularizationRequest,
|
||||||
|
getPendingRegularizations,
|
||||||
|
reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
||||||
|
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
const apiV1 = new Router();
|
||||||
|
|
||||||
|
apiV1.get("/attendance/logs", getRawLogs);
|
||||||
|
apiV1.post("/attendance/process-daily", processDailyAttendance);
|
||||||
|
|
||||||
|
// Regularization Routes
|
||||||
|
apiV1.post("/attendance/regularize", requireAuth, requireRole([AppRole.EMPLOYEE, AppRole.ADMIN]), createRegularizationRequest);
|
||||||
|
|
||||||
|
// Admin fetches requests assigned to their branches
|
||||||
|
apiV1.get("/attendance/regularize/pending", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingRegularizations);
|
||||||
|
|
||||||
|
// Admin reviews requests
|
||||||
|
apiV1.post("/attendance/regularize/review", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), reviewRegularizationRequest);
|
||||||
|
|
||||||
|
apiV1.get("/attendance/my-summary", getEmployeeSummary);
|
||||||
|
|
||||||
|
apiV1.get("/attendance/daily-report", getDailyReport);
|
||||||
|
apiV1.get("/attendance/admin-report", getAdminRangeReport);
|
||||||
|
apiV1.get("/attendance/employee-range-report", getSingleEmployeeRangeReport);
|
||||||
|
|
||||||
|
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
||||||
|
|
||||||
|
export default router;
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,253 +0,0 @@
|
|||||||
-- MySQL dump 10.13 Distrib 8.4.10, for Linux (x86_64)
|
|
||||||
--
|
|
||||||
-- Host: localhost Database: hrms_lms
|
|
||||||
-- ------------------------------------------------------
|
|
||||||
-- Server version 8.4.10
|
|
||||||
|
|
||||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
|
||||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
|
||||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
|
||||||
/*!50503 SET NAMES utf8mb4 */;
|
|
||||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
|
||||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
|
||||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
|
||||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
|
||||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
|
||||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `branch_work_settings`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `branch_work_settings`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `branch_work_settings` (
|
|
||||||
`setting_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`branch_id` int DEFAULT NULL,
|
|
||||||
`weekly_off_days` json NOT NULL,
|
|
||||||
`effective_from` date NOT NULL,
|
|
||||||
PRIMARY KEY (`setting_id`),
|
|
||||||
KEY `idx_work_settings` (`company_id`,`branch_id`)
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `branch_work_settings`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `branch_work_settings` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `branch_work_settings` DISABLE KEYS */;
|
|
||||||
INSERT INTO `branch_work_settings` VALUES (1,1,NULL,'[0]','2026-01-01');
|
|
||||||
/*!40000 ALTER TABLE `branch_work_settings` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `company_holidays`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `company_holidays`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `company_holidays` (
|
|
||||||
`holiday_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`branch_id` int DEFAULT NULL,
|
|
||||||
`calendar_year` int NOT NULL,
|
|
||||||
`holiday_date` date NOT NULL,
|
|
||||||
`holiday_type` enum('MANDATORY','OPTIONAL') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANDATORY',
|
|
||||||
`holiday_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
|
|
||||||
PRIMARY KEY (`holiday_id`),
|
|
||||||
KEY `idx_holiday_resolver` (`company_id`,`branch_id`,`calendar_year`,`holiday_date`)
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `company_holidays`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `company_holidays` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `company_holidays` DISABLE KEYS */;
|
|
||||||
INSERT INTO `company_holidays` VALUES (1,1,NULL,2026,'2026-08-15','MANDATORY','Independence Day');
|
|
||||||
/*!40000 ALTER TABLE `company_holidays` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `leave_allocations`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `leave_allocations`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `leave_allocations` (
|
|
||||||
`allocation_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`employee_id` int NOT NULL,
|
|
||||||
`leave_type_id` int NOT NULL,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`calendar_year` int NOT NULL,
|
|
||||||
`granted_days` decimal(4,1) NOT NULL,
|
|
||||||
`used_days` decimal(4,1) DEFAULT '0.0',
|
|
||||||
`status` enum('DRAFT','ACTIVE','EXPIRED') COLLATE utf8mb4_unicode_ci DEFAULT 'ACTIVE',
|
|
||||||
PRIMARY KEY (`allocation_id`),
|
|
||||||
KEY `leave_type_id` (`leave_type_id`),
|
|
||||||
KEY `idx_emp_balance` (`employee_id`,`calendar_year`,`leave_type_id`),
|
|
||||||
CONSTRAINT `leave_allocations_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `leave_allocations`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `leave_allocations` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `leave_allocations` DISABLE KEYS */;
|
|
||||||
INSERT INTO `leave_allocations` VALUES (1,1,1,1,2026,10.0,0.0,'ACTIVE');
|
|
||||||
/*!40000 ALTER TABLE `leave_allocations` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `leave_applications`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `leave_applications`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `leave_applications` (
|
|
||||||
`application_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`employee_id` int NOT NULL,
|
|
||||||
`leave_type_id` int NOT NULL,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`date_from` timestamp NOT NULL,
|
|
||||||
`date_to` timestamp NOT NULL,
|
|
||||||
`number_of_days` decimal(4,1) NOT NULL,
|
|
||||||
`reason` text COLLATE utf8mb4_unicode_ci NOT NULL,
|
|
||||||
`status` enum('DRAFT','PENDING','PENDING_LOP','APPROVED','APPROVED_LOP','REJECTED','CANCELLED') COLLATE utf8mb4_unicode_ci DEFAULT 'PENDING',
|
|
||||||
`manager_approved_by` int DEFAULT NULL,
|
|
||||||
`hr_approved_by` int DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`application_id`),
|
|
||||||
KEY `leave_type_id` (`leave_type_id`),
|
|
||||||
KEY `idx_lms_status_lookup` (`employee_id`,`status`),
|
|
||||||
CONSTRAINT `leave_applications_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE RESTRICT
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `leave_applications`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `leave_applications` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `leave_applications` DISABLE KEYS */;
|
|
||||||
/*!40000 ALTER TABLE `leave_applications` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `leave_ledger_transactions`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `leave_ledger_transactions`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `leave_ledger_transactions` (
|
|
||||||
`transaction_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`employee_id` int NOT NULL,
|
|
||||||
`leave_type_id` int NOT NULL,
|
|
||||||
`application_id` int DEFAULT NULL,
|
|
||||||
`calendar_year` int NOT NULL,
|
|
||||||
`calendar_month` int NOT NULL,
|
|
||||||
`transaction_type` enum('CREDIT','DEBIT','LAPSE') COLLATE utf8mb4_unicode_ci NOT NULL,
|
|
||||||
`days` decimal(4,1) NOT NULL,
|
|
||||||
`opening_balance` decimal(4,1) NOT NULL,
|
|
||||||
`closing_balance` decimal(4,1) NOT NULL,
|
|
||||||
`transaction_date` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`remarks` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`transaction_id`),
|
|
||||||
KEY `leave_type_id` (`leave_type_id`),
|
|
||||||
KEY `application_id` (`application_id`),
|
|
||||||
KEY `idx_ledger_lookup` (`employee_id`,`calendar_year`,`calendar_month`),
|
|
||||||
CONSTRAINT `leave_ledger_transactions_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`),
|
|
||||||
CONSTRAINT `leave_ledger_transactions_ibfk_2` FOREIGN KEY (`application_id`) REFERENCES `leave_applications` (`application_id`) ON DELETE SET NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `leave_ledger_transactions`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `leave_ledger_transactions` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `leave_ledger_transactions` DISABLE KEYS */;
|
|
||||||
/*!40000 ALTER TABLE `leave_ledger_transactions` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `leave_policy_rules`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `leave_policy_rules`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `leave_policy_rules` (
|
|
||||||
`rule_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`leave_type_id` int NOT NULL,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`branch_id` int DEFAULT NULL,
|
|
||||||
`calendar_year` int NOT NULL,
|
|
||||||
`yearly_allowance` decimal(4,1) NOT NULL,
|
|
||||||
`max_days_per_month` decimal(4,1) DEFAULT NULL,
|
|
||||||
`max_consecutive_days` decimal(4,1) DEFAULT NULL,
|
|
||||||
`apply_sandwich_policy` tinyint(1) DEFAULT '0',
|
|
||||||
PRIMARY KEY (`rule_id`),
|
|
||||||
KEY `leave_type_id` (`leave_type_id`),
|
|
||||||
KEY `idx_policy_lookup` (`company_id`,`branch_id`,`calendar_year`,`leave_type_id`),
|
|
||||||
CONSTRAINT `leave_policy_rules_ibfk_1` FOREIGN KEY (`leave_type_id`) REFERENCES `leave_types` (`leave_type_id`) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `leave_policy_rules`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `leave_policy_rules` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `leave_policy_rules` DISABLE KEYS */;
|
|
||||||
INSERT INTO `leave_policy_rules` VALUES (1,1,1,NULL,2026,10.0,2.0,NULL,1),(2,2,1,NULL,2026,4.0,NULL,NULL,0);
|
|
||||||
/*!40000 ALTER TABLE `leave_policy_rules` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Table structure for table `leave_types`
|
|
||||||
--
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `leave_types`;
|
|
||||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
|
||||||
/*!50503 SET character_set_client = utf8mb4 */;
|
|
||||||
CREATE TABLE `leave_types` (
|
|
||||||
`leave_type_id` int NOT NULL AUTO_INCREMENT,
|
|
||||||
`company_id` int NOT NULL,
|
|
||||||
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
|
|
||||||
`requires_allocation` tinyint(1) DEFAULT '1',
|
|
||||||
`carry_over_allowed` tinyint(1) DEFAULT '0',
|
|
||||||
`max_carry_over_days` decimal(3,1) DEFAULT '0.0',
|
|
||||||
PRIMARY KEY (`leave_type_id`)
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Dumping data for table `leave_types`
|
|
||||||
--
|
|
||||||
|
|
||||||
LOCK TABLES `leave_types` WRITE;
|
|
||||||
/*!40000 ALTER TABLE `leave_types` DISABLE KEYS */;
|
|
||||||
INSERT INTO `leave_types` VALUES (1,1,'Casual Leave',1,0,0.0),(2,1,'Optional Leave',1,0,0.0);
|
|
||||||
/*!40000 ALTER TABLE `leave_types` ENABLE KEYS */;
|
|
||||||
UNLOCK TABLES;
|
|
||||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
|
||||||
|
|
||||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
|
||||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
|
||||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
|
||||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
|
||||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
|
||||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
|
||||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
|
||||||
|
|
||||||
-- Dump completed on 2026-07-16 10:02:14
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
# 1. Export EMS Database
|
|
||||||
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ems' > hrms_ems_dump.sql
|
|
||||||
|
|
||||||
# 2. Export AMS Database
|
|
||||||
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_ams' > hrms_ams_dump.sql
|
|
||||||
|
|
||||||
# 3. Export LMS Database
|
|
||||||
docker exec hrms_db sh -c 'exec mysqldump -u root -proot hrms_lms' > hrms_lms_dump.sql
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
# 1. Import EMS Data
|
|
||||||
docker exec -i hrms_ems_db mysql -u root -proot hrms_ems < hrms_ems_dump.sql
|
|
||||||
|
|
||||||
# 2. Import AMS Data
|
|
||||||
docker exec -i hrms_ams_db mysql -u root -proot hrms_ams < hrms_ams_dump.sql
|
|
||||||
|
|
||||||
# 3. Import LMS Data
|
|
||||||
docker exec -i hrms_lms_db mysql -u root -proot hrms_lms < hrms_lms_dump.sql
|
|
||||||
12
deno.json
12
deno.json
@ -1,14 +1,12 @@
|
|||||||
{
|
{
|
||||||
"workspace": [
|
"workspace": [
|
||||||
"packages/core",
|
"./shared",
|
||||||
"services/ems",
|
"./ems-service",
|
||||||
"services/ams",
|
"./ams-service",
|
||||||
"services/lms",
|
"./lms-service"
|
||||||
"services/gateway"
|
|
||||||
],
|
],
|
||||||
"imports": {
|
"imports": {
|
||||||
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||||
"mysql2": "npm:mysql2@^3.0.0",
|
"mysql2": "npm:mysql2@^3.0.0"
|
||||||
"core/": "./packages/core/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
4
deno.lock
generated
4
deno.lock
generated
@ -89,8 +89,8 @@
|
|||||||
"is-property"
|
"is-property"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"iconv-lite@0.7.3": {
|
"iconv-lite@0.7.2": {
|
||||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"safer-buffer"
|
"safer-buffer"
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ems_db:
|
hrms_db:
|
||||||
image: docker.io/library/mysql:8.4
|
image: docker.io/library/mysql:8.4
|
||||||
container_name: hrms_ems_db
|
container_name: hrms_db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: root
|
MYSQL_ROOT_PASSWORD: root
|
||||||
MYSQL_DATABASE: hrms_ems
|
MYSQL_DATABASE: hrms_ems
|
||||||
@ -12,41 +12,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- ./init-db/01-init-ems.sql:/docker-entrypoint-initdb.d/01-init-ems.sql:Z
|
- ./init-db:/docker-entrypoint-initdb.d:Z
|
||||||
- ems_db_data:/var/lib/mysql
|
- hrms_db_data:/var/lib/mysql
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
ams_db:
|
|
||||||
image: docker.io/library/mysql:8.4
|
|
||||||
container_name: hrms_ams_db
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: root
|
|
||||||
MYSQL_DATABASE: hrms_ams
|
|
||||||
MYSQL_USER: admin
|
|
||||||
MYSQL_PASSWORD: admin123
|
|
||||||
ports:
|
|
||||||
- "3307:3306" # Maps host 3307 to container 3306
|
|
||||||
volumes:
|
|
||||||
- ./init-db/02-init-ams.sql:/docker-entrypoint-initdb.d/02-init-ams.sql:Z
|
|
||||||
- ams_db_data:/var/lib/mysql
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
lms_db:
|
|
||||||
image: docker.io/library/mysql:8.4
|
|
||||||
container_name: hrms_lms_db
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: root
|
|
||||||
MYSQL_DATABASE: hrms_lms
|
|
||||||
MYSQL_USER: admin
|
|
||||||
MYSQL_PASSWORD: admin123
|
|
||||||
ports:
|
|
||||||
- "3308:3306" # Maps host 3308 to container 3306
|
|
||||||
volumes:
|
|
||||||
- ./init-db/03-init-lms.sql:/docker-entrypoint-initdb.d/03-init-lms.sql:Z
|
|
||||||
- lms_db_data:/var/lib/mysql
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ems_db_data:
|
hrms_db_data:
|
||||||
ams_db_data:
|
|
||||||
lms_db_data:
|
|
||||||
68
ems-service/controllers/contract.controller.ts
Normal file
68
ems-service/controllers/contract.controller.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts"
|
||||||
|
|
||||||
|
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
|
||||||
|
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 };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createContract = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Expire old assignment
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
|
||||||
|
[data.employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Insert new assignment (Promotion/Transfer)
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
|
||||||
|
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 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(); }
|
||||||
|
};
|
||||||
66
ems-service/controllers/dashboard.controller.ts
Normal file
66
ems-service/controllers/dashboard.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
310
ems-service/controllers/employee.controller.ts
Normal file
310
ems-service/controllers/employee.controller.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
import { generateNextCode } from "../../shared/sequence.ts";
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
export const getEmployees = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
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 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[] = [];
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmployee = async (ctx: any) => {
|
||||||
|
const body = ctx.request.body;
|
||||||
|
if (body.type() !== "json") { ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body" }; return; }
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
let empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
|
||||||
|
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
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.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 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`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", 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. Employee Code, Email, or Phone already exists." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: "Transaction failed: " + errorMessage };
|
||||||
|
} 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 {
|
||||||
|
// 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,
|
||||||
|
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 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
const empData = empRows as any[];
|
||||||
|
if (empData.length === 0) { ctx.response.status = 404; ctx.response.body = { success: false, message: "Employee not found" }; return; }
|
||||||
|
|
||||||
|
const employee = empData[0];
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
employee_id: employee.employee_id, employee_code: employee.employee_code, is_active: employee.is_active,
|
||||||
|
first_name: employee.first_name, last_name: employee.last_name, dob: employee.dob, gender: employee.gender,
|
||||||
|
personal_email: employee.personal_email, personal_phone: employee.personal_phone,
|
||||||
|
contract: {
|
||||||
|
work_email: employee.work_email, date_joining: employee.date_joining, probation_days: employee.probation_days,
|
||||||
|
status: employee.contract_status, 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEmployee = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const body = ctx.request.body;
|
||||||
|
|
||||||
|
if (body.type() !== "json") {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: "Invalid JSON body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Check if employee exists and get partner_id
|
||||||
|
const [employeeRows] = await connection.execute(
|
||||||
|
`SELECT partner_id FROM employees WHERE employee_id = ?`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const employees = employeeRows as any[];
|
||||||
|
if (employees.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Employee not found" };
|
||||||
|
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 = ?, dob = ?, gender = ?, personal_email = ?, personal_phone = ?
|
||||||
|
WHERE partner_id = ?`,
|
||||||
|
[
|
||||||
|
data.firstName,
|
||||||
|
data.lastName,
|
||||||
|
data.dob,
|
||||||
|
data.gender,
|
||||||
|
data.personalEmail,
|
||||||
|
data.personalPhone,
|
||||||
|
partnerId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
door_number = VALUES(door_number),
|
||||||
|
landmark = VALUES(landmark),
|
||||||
|
address_line = VALUES(address_line),
|
||||||
|
pincode = VALUES(pincode),
|
||||||
|
district = VALUES(district),
|
||||||
|
state = VALUES(state)`,
|
||||||
|
[
|
||||||
|
partnerId,
|
||||||
|
data.address.type,
|
||||||
|
data.address.doorNumber,
|
||||||
|
data.address.landmark,
|
||||||
|
data.address.line,
|
||||||
|
data.address.pincode,
|
||||||
|
data.address.district,
|
||||||
|
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();
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Employee profile updated successfully",
|
||||||
|
};
|
||||||
|
} 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: " + err.message,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEmployee = async (ctx: any) => {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(`UPDATE employees SET is_active = false WHERE employee_id = ?`, [id]);
|
||||||
|
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" };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: "Deactivation failed: " + (error as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
343
ems-service/controllers/lookup.controller.ts
Normal file
343
ems-service/controllers/lookup.controller.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { getDbPool } from "../../shared/db.ts"
|
||||||
|
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 { 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 }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBranches = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
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 }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDepartments = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
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 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; }
|
||||||
|
const data = await body.json();
|
||||||
|
|
||||||
|
let companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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, company_code: companyCode };
|
||||||
|
} catch (error) {
|
||||||
|
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; }
|
||||||
|
const data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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 }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 (!branchCode || branchCode.trim() === "") {
|
||||||
|
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
|
||||||
|
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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 };
|
||||||
|
} catch (error) {
|
||||||
|
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: 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 data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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 }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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; }
|
||||||
|
|
||||||
|
let deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`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;
|
||||||
|
|
||||||
|
if (data.managerId) {
|
||||||
|
await connection.execute(
|
||||||
|
`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, department_code: deptCode };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
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 data = await body.json();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [result] = await connection.execute(
|
||||||
|
`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 (data.managerId) {
|
||||||
|
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, 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" };
|
||||||
|
} 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;
|
||||||
|
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; }
|
||||||
|
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; }
|
||||||
|
|
||||||
|
let jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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, job_code: jobCode };
|
||||||
|
} catch (error) {
|
||||||
|
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 data = await body.json();
|
||||||
|
try {
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`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 }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
155
ems-service/controllers/system.controller.ts
Normal file
155
ems-service/controllers/system.controller.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
|
||||||
|
const emsPool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
|
export const bulkSeedEmployees = async (ctx: Context) => {
|
||||||
|
const body = ctx.request.hasBody ? await ctx.request.body.json() : [];
|
||||||
|
if (!Array.isArray(body) || body.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide an array of employee logs." }; return; }
|
||||||
|
|
||||||
|
const connection = await emsPool.getConnection();
|
||||||
|
let insertedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
for (const item of body) {
|
||||||
|
const { emp_code, name, designation, department, email } = item;
|
||||||
|
const nameParts = name.trim().split(" ");
|
||||||
|
const firstName = nameParts[0] || "Employee";
|
||||||
|
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
||||||
|
|
||||||
|
// 1. 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', ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
|
||||||
|
[firstName, lastName, `personal.${email}`, `MOCK_${emp_code}`]
|
||||||
|
);
|
||||||
|
const partnerId = partnerResult.insertId;
|
||||||
|
|
||||||
|
// 2. Department
|
||||||
|
const [deptResult]: any = await connection.execute(
|
||||||
|
`INSERT INTO departments (company_id, branch_id, department_code, name)
|
||||||
|
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
||||||
|
[department || "General", department || "General"]
|
||||||
|
);
|
||||||
|
const departmentId = deptResult.insertId;
|
||||||
|
|
||||||
|
// 3. Job
|
||||||
|
const [jobResult]: any = await connection.execute(
|
||||||
|
`INSERT INTO job_positions (department_id, job_code, title)
|
||||||
|
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
||||||
|
[departmentId, designation || "Trainee", designation || "Trainee"]
|
||||||
|
);
|
||||||
|
const jobId = jobResult.insertId;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
|
||||||
|
[emp_code, partnerId]
|
||||||
|
);
|
||||||
|
const employeeId = empResult.insertId;
|
||||||
|
|
||||||
|
// 5. Terms & Assignments (Replaces old contracts insert)
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
||||||
|
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
|
||||||
|
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
|
||||||
|
[employeeId, email]
|
||||||
|
);
|
||||||
|
|
||||||
|
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.` };
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("Seeding failed, changes rolled back:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: errorMessage };
|
||||||
|
} finally { connection.release(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkMapHierarchy = async (ctx: Context) => {
|
||||||
|
const body = ctx.request.hasBody ? await ctx.request.body.json() : {};
|
||||||
|
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
|
||||||
|
|
||||||
|
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) { ctx.response.status = 400; ctx.response.body = { success: false, message: "Provide hierarchy mapping arrays." }; return; }
|
||||||
|
|
||||||
|
const connection = await emsPool.getConnection();
|
||||||
|
let assignmentsUpdated = 0;
|
||||||
|
let departmentsUpdated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 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 employee_assignments ea
|
||||||
|
JOIN employees e ON ea.employee_id = e.employee_id
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
SET ea.reporting_to_id = m.employee_id
|
||||||
|
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
|
||||||
|
[manager_code, emp_code]
|
||||||
|
);
|
||||||
|
if (result.affectedRows > 0) assignmentsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Map Departments to Managers and Parent Departments
|
||||||
|
for (const dept of departmentHierarchy) {
|
||||||
|
const { department_name, manager_code, parent_department_name } = dept;
|
||||||
|
if (!department_name) continue;
|
||||||
|
|
||||||
|
if (manager_code) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
|
||||||
|
SELECT d.department_id, m.employee_id, TRUE
|
||||||
|
FROM departments d
|
||||||
|
JOIN employees m ON m.employee_code = ?
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[manager_code, department_name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent_department_name) {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE departments d
|
||||||
|
JOIN departments p ON p.name = ?
|
||||||
|
SET d.parent_id = p.department_id
|
||||||
|
WHERE d.name = ?`,
|
||||||
|
[parent_department_name, department_name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
departmentsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Hierarchy mapping completed successfully.", metrics: { 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(); }
|
||||||
|
};
|
||||||
63
ems-service/routes.ts
Normal file
63
ems-service/routes.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {getDashboardMetrics} from "./controllers/dashboard.controller.ts"
|
||||||
|
import {
|
||||||
|
getEmployees, createEmployee, getEmployeeById, updateEmployee, deleteEmployee, getEmployeeHistory
|
||||||
|
} from "./controllers/employee.controller.ts";
|
||||||
|
import {
|
||||||
|
getCompanies, createCompany, updateCompany, deleteCompany,
|
||||||
|
getBranches, createBranch, updateBranch, deleteBranch,
|
||||||
|
getDepartments, createDepartment, updateDepartment, deleteDepartment,
|
||||||
|
getJobs, createJob, updateJob, deleteJob
|
||||||
|
} from "./controllers/lookup.controller.ts";
|
||||||
|
import {
|
||||||
|
getEmployeeContracts, createContract
|
||||||
|
} from "./controllers/contract.controller.ts";
|
||||||
|
import {
|
||||||
|
bulkSeedEmployees, bulkMapHierarchy
|
||||||
|
} from "./controllers/system.controller.ts";
|
||||||
|
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// CONTRACT & ASSIGNMENT DOMAIN (Promotions/Transfers)
|
||||||
|
router.get("/employees/:id/contracts", requireAuth, getEmployeeContracts);
|
||||||
|
router.post("/contracts", requireAuth, requireRole([AppRole.SUPER_ADMIN, AppRole.ADMIN]), createContract);
|
||||||
|
|
||||||
|
// ORGANIZATIONAL LOOKUP DOMAIN
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// DASHBOARD METRICS
|
||||||
|
// Example: GET /dashboard/metrics?company_id=1&branch_id=2
|
||||||
|
router.get("/dashboard/metrics", requireAuth, getDashboardMetrics);
|
||||||
|
|
||||||
|
// SYSTEM & MIGRATION DOMAIN
|
||||||
|
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;
|
||||||
69
ems-service/routes.ts.bkp
Normal file
69
ems-service/routes.ts.bkp
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
getEmployees,
|
||||||
|
createEmployee,
|
||||||
|
getEmployeeById,
|
||||||
|
updateEmployee,
|
||||||
|
deleteEmployee
|
||||||
|
} from "./controllers/employee.controller.ts";
|
||||||
|
import {
|
||||||
|
getCompanies,
|
||||||
|
getBranches,
|
||||||
|
getDepartments,
|
||||||
|
getJobs
|
||||||
|
} from "./controllers/lookup.controller.ts";
|
||||||
|
import {
|
||||||
|
getEmployeeContracts,
|
||||||
|
createContract
|
||||||
|
} from "./controllers/contract.controller.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bulkSeedEmployees,
|
||||||
|
bulkMapHierarchy
|
||||||
|
} from "./controllers/system.controller.ts"
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
// --- Employee Core Endpoints ---
|
||||||
|
|
||||||
|
// Retrieve all employees
|
||||||
|
router.get("/api/v1/employees", getEmployees);
|
||||||
|
|
||||||
|
// Retrieve a single employee's comprehensive profile
|
||||||
|
router.get("/api/v1/employees/:id", getEmployeeById);
|
||||||
|
|
||||||
|
// Create a new employee
|
||||||
|
router.post("/api/v1/employees", createEmployee);
|
||||||
|
|
||||||
|
// Update an existing employee
|
||||||
|
router.put("/api/v1/employees/:id", updateEmployee);
|
||||||
|
|
||||||
|
// Deactivate an employee
|
||||||
|
router.delete("/api/v1/employees/:id", deleteEmployee);
|
||||||
|
|
||||||
|
// --- Contract Endpoints ---
|
||||||
|
|
||||||
|
//Retrieve contract history for an individual employee
|
||||||
|
router.get("/api/v1/employees/:id/contracts", getEmployeeContracts);
|
||||||
|
|
||||||
|
//Terminate, transition, or execute a new employment agreement.
|
||||||
|
router.post("/api/v1/contracts", createContract);
|
||||||
|
|
||||||
|
// --- Organizational Lookup Endpoints ---
|
||||||
|
|
||||||
|
// Fetch legal entities and parent group structures.
|
||||||
|
router.get("/api/v1/companies", getCompanies);
|
||||||
|
|
||||||
|
//Fetch structural branch offices
|
||||||
|
router.get("/api/v1/branches", getBranches);
|
||||||
|
|
||||||
|
//List corporate departments and organizational chart reporting lines
|
||||||
|
router.get("/api/v1/departments", getDepartments);
|
||||||
|
|
||||||
|
//List company designations and employment titles.
|
||||||
|
router.get("/api/v1/jobs", getJobs);
|
||||||
|
|
||||||
|
router.post("/api/v1/system/seed-employees", bulkSeedEmployees);
|
||||||
|
router.post("/api/v1/system/map-hierarchy", bulkMapHierarchy);
|
||||||
|
|
||||||
|
export default router;
|
||||||
46
init-db/02-dummy-data.sql
Normal file
46
init-db/02-dummy-data.sql
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
-- Select the right database context
|
||||||
|
USE hrms_ems;
|
||||||
|
|
||||||
|
-- 1. Create the Company
|
||||||
|
INSERT INTO companies (company_id, name, parent_id)
|
||||||
|
VALUES (1, 'CliniLaunch Research', NULL);
|
||||||
|
|
||||||
|
-- 2. Create a Branch
|
||||||
|
INSERT INTO branches (branch_id, company_id, branch_name, code)
|
||||||
|
VALUES (1, 1, 'Bengaluru Main', 'BLR01');
|
||||||
|
|
||||||
|
-- 3. Define Departments
|
||||||
|
INSERT INTO departments (department_id, company_id, name, parent_id, manager_id)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 'Training (PT)', NULL, NULL),
|
||||||
|
(2, 1, 'IT', NULL, NULL);
|
||||||
|
|
||||||
|
-- 4. Authorize Job Positions
|
||||||
|
INSERT INTO job_positions (job_id, company_id, title, description)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 'Executive-II (PT)', 'Part-time executive role for training.'),
|
||||||
|
(2, 1, 'Trainee', 'Entry level IT trainee.');
|
||||||
|
|
||||||
|
-- 5. Create Partners (The personal identity)
|
||||||
|
INSERT INTO partners (partner_id, first_name, last_name, dob, gender, personal_email, personal_phone, profile_picture_url)
|
||||||
|
VALUES
|
||||||
|
(1, 'Blessy', 'Paul', '1995-06-15', 'Female', 'blessy.personal@example.com', '9876543210', NULL),
|
||||||
|
(2, 'Adhvaidh', 'Prasad', '1998-11-22', 'Male', 'adhvaidh.personal@example.com', '8765432109', NULL);
|
||||||
|
|
||||||
|
-- 6. Add Addresses for the Partners
|
||||||
|
INSERT INTO addresses (address_id, partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 'CURRENT', '42/A', 'Near Metro', 'MG Road', '560001', 'Bengaluru', 'Karnataka'),
|
||||||
|
(2, 2, 'PERMANENT', '15-B', 'Tech Park', 'Whitefield', '560066', 'Bengaluru', 'Karnataka');
|
||||||
|
|
||||||
|
-- 7. Employ the Partners
|
||||||
|
INSERT INTO employees (employee_id, employee_code, partner_id, company_id, branch_id, is_active)
|
||||||
|
VALUES
|
||||||
|
(1, 'CLRI18', 1, 1, 1, TRUE),
|
||||||
|
(2, 'CLRI298', 2, 1, 1, TRUE);
|
||||||
|
|
||||||
|
-- 8. Assign Contracts
|
||||||
|
INSERT INTO contracts (contract_id, employee_id, department_id, job_id, reporting_to_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 1, 1, NULL, 'Blessy.Paul@clinilaunchresearch.in', '2023-01-10', 90, 'ACTIVE', 101),
|
||||||
|
(2, 2, 2, 2, 1, 'adhvaidh.P@clinilaunchresearch.in', '2023-05-20', 180, 'ACTIVE', 102);
|
||||||
@ -51,7 +51,6 @@ CREATE TABLE processed_daily_attendance (
|
|||||||
CREATE TABLE attendance_regularizations (
|
CREATE TABLE attendance_regularizations (
|
||||||
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
||||||
branch_id INT NOT NULL,
|
|
||||||
attendance_id INT NULL, -- Internal FK to processed ledger row
|
attendance_id INT NULL, -- Internal FK to processed ledger row
|
||||||
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
|
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') NOT NULL,
|
||||||
target_date DATE NOT NULL,
|
target_date DATE NOT NULL,
|
||||||
153
lms-service/controllers/admin.controller.ts
Normal file
153
lms-service/controllers/admin.controller.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
// lms-service/controllers/admin.controller.ts
|
||||||
|
|
||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
|
||||||
|
const db = getDbPool("hrms_lms");
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LEAVE TYPES
|
||||||
|
// ==========================================
|
||||||
|
export const createLeaveType = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days } = body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result]: any = await db.execute(
|
||||||
|
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[company_id, name, requires_allocation ?? true, carry_over_allowed ?? false, max_carry_over_days ?? 0.0]
|
||||||
|
);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, message: "Leave type created", leave_type_id: result.insertId };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLeaveTypes = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const companyId = params.get("company_id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows]: any = await db.execute(
|
||||||
|
`SELECT * FROM leave_types WHERE company_id = ?`,
|
||||||
|
[companyId]
|
||||||
|
);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, count: rows.length, data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// POLICY RULES
|
||||||
|
// ==========================================
|
||||||
|
export const createPolicyRule = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result]: any = await db.execute(
|
||||||
|
`INSERT INTO leave_policy_rules
|
||||||
|
(leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
body.leave_type_id,
|
||||||
|
body.company_id,
|
||||||
|
body.branch_id || null,
|
||||||
|
body.calendar_year,
|
||||||
|
body.yearly_allowance,
|
||||||
|
body.max_days_per_month || null,
|
||||||
|
body.max_consecutive_days || null,
|
||||||
|
body.apply_sandwich_policy ?? false
|
||||||
|
]
|
||||||
|
);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, message: "Policy rule created", rule_id: result.insertId };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HOLIDAY CALENDAR
|
||||||
|
// ==========================================
|
||||||
|
export const createCompanyHoliday = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await ctx.request.body.json(); // Expects an array of holiday objects for bulk insert
|
||||||
|
|
||||||
|
if (!Array.isArray(body)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Expected an array of holidays for bulk insert." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Map the array of objects to a flat array for MySQL bulk insert
|
||||||
|
const values = body.map(h => [
|
||||||
|
h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`,
|
||||||
|
[values]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, message: `${values.length} holidays inserted successfully.` };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// WORK SETTINGS
|
||||||
|
// ==========================================
|
||||||
|
export const updateWorkSettings = async (ctx: Context) => {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { company_id, branch_id, weekly_off_days, effective_from } = body;
|
||||||
|
|
||||||
|
// weekly_off_days should be an array like [0] for Sunday. MySQL JSON column accepts stringified arrays.
|
||||||
|
const offDaysJson = JSON.stringify(weekly_off_days);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upsert logic: If settings exist for this branch/company, update them. Otherwise, insert.
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
|
||||||
|
[company_id, branch_id || null, offDaysJson, effective_from]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, message: "Work settings updated successfully." };
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
140
lms-service/controllers/employee.controller.ts
Normal file
140
lms-service/controllers/employee.controller.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// 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];
|
||||||
|
|
||||||
|
// FIX: Fallback to null if undefined to prevent MySQL driver crashes
|
||||||
|
const companyId = user.company_id ?? null;
|
||||||
|
const branchId = user.branch_id ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch optional holidays for the employee's company/branch that are today or in the future
|
||||||
|
const [rows]: any = await db.execute(
|
||||||
|
`SELECT
|
||||||
|
holiday_id,
|
||||||
|
holiday_name,
|
||||||
|
DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
|
||||||
|
FROM company_holidays
|
||||||
|
WHERE company_id = ?
|
||||||
|
AND calendar_year = ?
|
||||||
|
AND holiday_type = 'OPTIONAL'
|
||||||
|
AND (branch_id = ? OR branch_id IS NULL)
|
||||||
|
AND holiday_date >= ?
|
||||||
|
ORDER BY holiday_date ASC`,
|
||||||
|
[companyId, currentYear, branchId, today]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
count: rows.length,
|
||||||
|
holidays: rows
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch optional holidays:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
184
lms-service/controllers/leave.controller.ts
Normal file
184
lms-service/controllers/leave.controller.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// lms-service/controllers/leave.controller.ts
|
||||||
|
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
import { calculateLeaveDays } from '../../shared/calendar.service.ts';
|
||||||
|
|
||||||
|
const db = getDbPool("hrms_lms");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload interface expected from the Frontend UI
|
||||||
|
*/
|
||||||
|
export interface LeaveApplicationPayload {
|
||||||
|
employee_id: number;
|
||||||
|
leave_type_id: number;
|
||||||
|
company_id: number;
|
||||||
|
branch_id: number; // Used for fetching specific holiday/work settings
|
||||||
|
date_from: string; // YYYY-MM-DD
|
||||||
|
date_to: string; // YYYY-MM-DD
|
||||||
|
is_half_day: boolean;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyForLeave = async (ctx: any) => {
|
||||||
|
try {
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Missing request body" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: LeaveApplicationPayload = await ctx.request.body.json();
|
||||||
|
const currentYear = new Date(payload.date_from).getFullYear();
|
||||||
|
const currentMonth = new Date(payload.date_from).getMonth() + 1; // 1-12
|
||||||
|
|
||||||
|
// 1. Fetch Configuration & Rules
|
||||||
|
const [branchSettingsRows]: any = await db.execute(
|
||||||
|
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
||||||
|
[payload.company_id, payload.branch_id]
|
||||||
|
);
|
||||||
|
const branchSettingsRaw = branchSettingsRows[0];
|
||||||
|
const workSettings = { weeklyOffDays: branchSettingsRaw?.weekly_off_days || [0] };
|
||||||
|
|
||||||
|
const [holidays]: any = await db.execute(
|
||||||
|
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
|
||||||
|
[payload.company_id, currentYear, payload.branch_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [policyRows]: any = await db.execute(
|
||||||
|
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
||||||
|
[payload.leave_type_id, payload.company_id, currentYear, payload.branch_id]
|
||||||
|
);
|
||||||
|
const policy = policyRows[0];
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Leave policy not configured for this type/year." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Calculate Requested Days using Shared Service
|
||||||
|
let requestedDays = 0;
|
||||||
|
if (payload.is_half_day) {
|
||||||
|
if (payload.date_from !== payload.date_to) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Half-day leaves must be on the same date." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestedDays = 0.5;
|
||||||
|
} else {
|
||||||
|
requestedDays = calculateLeaveDays(
|
||||||
|
payload.date_from,
|
||||||
|
payload.date_to,
|
||||||
|
workSettings,
|
||||||
|
holidays,
|
||||||
|
policy.apply_sandwich_policy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedDays === 0) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { error: "Selected dates fall entirely on weekends/holidays with no sandwich policy applied." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch Balances & Monthly Limits
|
||||||
|
// FIX: Fetch raw balance first
|
||||||
|
const [balanceRows]: any = await db.execute(
|
||||||
|
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
|
||||||
|
[payload.employee_id, payload.leave_type_id, currentYear]
|
||||||
|
);
|
||||||
|
const rawBalance = Number(balanceRows[0]?.raw_balance || 0);
|
||||||
|
|
||||||
|
// FIX: Fetch pending leaves to prevent over-application
|
||||||
|
const [pendingRows]: any = await db.execute(
|
||||||
|
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
||||||
|
[payload.employee_id, payload.leave_type_id, currentYear]
|
||||||
|
);
|
||||||
|
const pendingDays = Number(pendingRows[0]?.pending_days || 0);
|
||||||
|
|
||||||
|
// Calculate actual available balance
|
||||||
|
const availableBalance = rawBalance - pendingDays;
|
||||||
|
|
||||||
|
// Check how many days the employee has already taken this month for this leave type
|
||||||
|
const [usageRows]: any = await db.execute(
|
||||||
|
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
|
||||||
|
[payload.employee_id, payload.leave_type_id, currentMonth, currentYear]
|
||||||
|
);
|
||||||
|
const usedThisMonth = Number(usageRows[0]?.used_this_month || 0);
|
||||||
|
|
||||||
|
// 4. Determine Paid vs LOP Split
|
||||||
|
let paidDays = 0;
|
||||||
|
let lopDays = 0;
|
||||||
|
|
||||||
|
const remainingMonthlyLimit = policy.max_days_per_month !== null
|
||||||
|
? Math.max(0, policy.max_days_per_month - usedThisMonth)
|
||||||
|
: requestedDays; // If no limit, they can take all requests if balance permits
|
||||||
|
|
||||||
|
const maxAllowedPaid = Math.min(availableBalance, remainingMonthlyLimit);
|
||||||
|
|
||||||
|
if (requestedDays <= maxAllowedPaid) {
|
||||||
|
paidDays = requestedDays;
|
||||||
|
} else {
|
||||||
|
paidDays = maxAllowedPaid;
|
||||||
|
lopDays = requestedDays - paidDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fetch Manager ID via Cross-Database Query (Fixes hardcoded HTTP call)
|
||||||
|
const [managerRows]: any = await db.execute(
|
||||||
|
`SELECT c.reporting_to_id
|
||||||
|
FROM hrms_ems.employees e
|
||||||
|
JOIN hrms_ems.contracts c ON e.employee_id = c.employee_id
|
||||||
|
WHERE e.employee_id = ? AND c.status = 'ACTIVE'`,
|
||||||
|
[payload.employee_id]
|
||||||
|
);
|
||||||
|
const managerId = managerRows[0]?.reporting_to_id || null;
|
||||||
|
|
||||||
|
// 6. Execute Database Transaction for Auto-Split
|
||||||
|
const connection = await db.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const insertedIds = [];
|
||||||
|
|
||||||
|
// Insert Paid Record
|
||||||
|
if (paidDays > 0) {
|
||||||
|
const [result]: any = await connection.execute(
|
||||||
|
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?)`,
|
||||||
|
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, paidDays, payload.reason, managerId]
|
||||||
|
);
|
||||||
|
insertedIds.push(result.insertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert LOP Record (Auto-Split)
|
||||||
|
if (lopDays > 0) {
|
||||||
|
const [result]: any = await connection.execute(
|
||||||
|
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING_LOP', ?)`,
|
||||||
|
[payload.employee_id, payload.leave_type_id, payload.company_id, payload.date_from, payload.date_to, lopDays, payload.reason, managerId]
|
||||||
|
);
|
||||||
|
insertedIds.push(result.insertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
message: "Leave application submitted successfully.",
|
||||||
|
total_requested: requestedDays,
|
||||||
|
paid_days: paidDays,
|
||||||
|
lop_days: lopDays,
|
||||||
|
application_ids: insertedIds
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (dbError) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw dbError;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
ctx.response.body = { error: "Internal Server Error", details: errorMessage };
|
||||||
|
}
|
||||||
|
};
|
||||||
209
lms-service/controllers/manager.controller.ts
Normal file
209
lms-service/controllers/manager.controller.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
// lms-service/controllers/manager.controller.ts
|
||||||
|
|
||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
|
||||||
|
const db = getDbPool("hrms_lms");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MANAGER: Fetch all pending leave applications for subordinates
|
||||||
|
*/
|
||||||
|
export const getPendingManagerLeaves = async (ctx: Context) => {
|
||||||
|
const user = ctx.state.user; // Logged-in Manager's data from auth.ts
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cross-database join to get applicant details from EMS
|
||||||
|
const [rows]: any = await db.execute(
|
||||||
|
`SELECT
|
||||||
|
la.application_id,
|
||||||
|
la.employee_id,
|
||||||
|
la.leave_type_id,
|
||||||
|
lt.name AS leave_type,
|
||||||
|
la.date_from,
|
||||||
|
la.date_to,
|
||||||
|
la.number_of_days,
|
||||||
|
la.reason,
|
||||||
|
la.status,
|
||||||
|
e.employee_code,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name
|
||||||
|
FROM leave_applications la
|
||||||
|
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
|
||||||
|
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
||||||
|
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
WHERE la.manager_approved_by = ?
|
||||||
|
AND la.status IN ('PENDING', 'PENDING_LOP')
|
||||||
|
ORDER BY la.date_from ASC`,
|
||||||
|
[user.employee_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
count: rows.length,
|
||||||
|
data: rows
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch manager pending leaves:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MANAGER: Approve a leave request (Triggers Ledger DEBIT & Balance Update)
|
||||||
|
*/
|
||||||
|
export const approveLeaveManager = async (ctx: Context) => {
|
||||||
|
const applicationId = ctx.params.applicationId;
|
||||||
|
const user = ctx.state.user; // The Manager approving it
|
||||||
|
const connection = await db.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Lock the leave application row for update to prevent race conditions
|
||||||
|
const [leaveRows]: any = await connection.execute(
|
||||||
|
`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`,
|
||||||
|
[applicationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leaveRows.length === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Leave application not found." };
|
||||||
|
await connection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = leaveRows[0];
|
||||||
|
|
||||||
|
// Security: Ensure this manager is actually assigned to this leave
|
||||||
|
if (leave.manager_approved_by !== user.employee_id) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
ctx.response.body = { success: false, message: "Access Denied: You are not the assigned manager for this leave." };
|
||||||
|
await connection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-approval
|
||||||
|
if (!['PENDING', 'PENDING_LOP'].includes(leave.status)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: `Leave is already processed. Current status: ${leave.status}` };
|
||||||
|
await connection.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine new status
|
||||||
|
const newStatus = leave.status === 'PENDING_LOP' ? 'APPROVED_LOP' : 'APPROVED';
|
||||||
|
|
||||||
|
// 3. Update Leave Application Status
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE leave_applications SET status = ? WHERE application_id = ?`,
|
||||||
|
[newStatus, applicationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Ledger & Balance Updates (Only for Paid Leaves, skip for LOP)
|
||||||
|
if (newStatus === 'APPROVED') {
|
||||||
|
const currentYear = new Date(leave.date_from).getFullYear();
|
||||||
|
const currentMonth = new Date(leave.date_from).getMonth() + 1;
|
||||||
|
|
||||||
|
// Fetch current balance to calculate Opening Balance (OB) for the ledger
|
||||||
|
const [allocRows]: any = await connection.execute(
|
||||||
|
`SELECT granted_days, used_days FROM leave_allocations
|
||||||
|
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
|
||||||
|
[leave.employee_id, leave.leave_type_id, currentYear]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allocRows.length > 0) {
|
||||||
|
const alloc = allocRows[0];
|
||||||
|
const openingBalance = Number(alloc.granted_days) - Number(alloc.used_days);
|
||||||
|
const closingBalance = openingBalance - Number(leave.number_of_days);
|
||||||
|
|
||||||
|
// A. Write to Immutable Ledger (The Excel OB/CB replacement)
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO leave_ledger_transactions
|
||||||
|
(employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
leave.employee_id,
|
||||||
|
leave.leave_type_id,
|
||||||
|
applicationId,
|
||||||
|
currentYear,
|
||||||
|
currentMonth,
|
||||||
|
leave.number_of_days,
|
||||||
|
openingBalance,
|
||||||
|
closingBalance,
|
||||||
|
`Approved by Manager ID: ${user.employee_id}`
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// B. Update the Allocations Table (Speeds up frontend dashboard loads)
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE leave_allocations SET used_days = used_days + ?
|
||||||
|
WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`,
|
||||||
|
[leave.number_of_days, leave.employee_id, leave.leave_type_id, currentYear]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Leave approved successfully.",
|
||||||
|
new_status: newStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
console.error("Manager approval failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MANAGER: Reject a leave request
|
||||||
|
*/
|
||||||
|
export const rejectLeaveManager = async (ctx: Context) => {
|
||||||
|
const applicationId = ctx.params.applicationId;
|
||||||
|
const user = ctx.state.user;
|
||||||
|
|
||||||
|
if (!ctx.request.hasBody) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing rejection reason in body." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const rejectionReason = body.reason;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result]: any = await db.execute(
|
||||||
|
`UPDATE leave_applications
|
||||||
|
SET status = 'REJECTED'
|
||||||
|
WHERE application_id = ? AND manager_approved_by = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
||||||
|
[applicationId, user.employee_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
ctx.response.body = { success: false, message: "Leave not found, already processed, or you lack permission." };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: You could log this rejection in an audit table or leave_remarks table here
|
||||||
|
// using the `rejectionReason` variable.
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
message: "Leave rejected successfully."
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Manager rejection failed:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
75
lms-service/controllers/reports.controller.ts
Normal file
75
lms-service/controllers/reports.controller.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// lms-service/controllers/reports.controller.ts
|
||||||
|
|
||||||
|
import { Context } from "@oak/oak";
|
||||||
|
import { getDbPool } from "../../shared/db.ts";
|
||||||
|
|
||||||
|
const db = getDbPool("hrms_lms");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADMIN: Generate the Excel-style OB/CB Report for a specific month
|
||||||
|
* Query: ?month=5&year=2026&company_id=1
|
||||||
|
*/
|
||||||
|
export const getLedgerReport = async (ctx: Context) => {
|
||||||
|
const params = ctx.request.url.searchParams;
|
||||||
|
const month = Number(params.get("month"));
|
||||||
|
const year = Number(params.get("year"));
|
||||||
|
const companyId = Number(params.get("company_id"));
|
||||||
|
|
||||||
|
if (!month || !year || !companyId) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, message: "Missing required query params: month, year, company_id" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This query ensures every employee is listed, even if they had 0 transactions that month.
|
||||||
|
// It fetches the closing balance of the LAST transaction BEFORE the month started (OB)
|
||||||
|
// And the closing balance of the LAST transaction UP TO the END of the month (CB).
|
||||||
|
const [rows]: any = await db.execute(
|
||||||
|
`SELECT
|
||||||
|
e.employee_id,
|
||||||
|
e.employee_code,
|
||||||
|
CONCAT(p.first_name, ' ', p.last_name) AS employee_name,
|
||||||
|
lt.name AS leave_type,
|
||||||
|
la.granted_days AS total_granted,
|
||||||
|
la.used_days AS total_used,
|
||||||
|
|
||||||
|
-- Opening Balance: Closing balance of the last transaction BEFORE the target month
|
||||||
|
COALESCE((
|
||||||
|
SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob
|
||||||
|
WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id
|
||||||
|
AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?))
|
||||||
|
ORDER BY t_ob.transaction_date DESC LIMIT 1
|
||||||
|
), la.granted_days) AS opening_balance,
|
||||||
|
|
||||||
|
-- Closing Balance: Closing balance of the last transaction UP TO the END of the target month
|
||||||
|
COALESCE((
|
||||||
|
SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb
|
||||||
|
WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id
|
||||||
|
AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?))
|
||||||
|
ORDER BY t_cb.transaction_date DESC LIMIT 1
|
||||||
|
), la.granted_days) AS closing_balance
|
||||||
|
|
||||||
|
FROM leave_allocations la
|
||||||
|
INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
||||||
|
INNER JOIN hrms_ems.employees e ON la.employee_id = e.employee_id
|
||||||
|
INNER JOIN hrms_ems.partners p ON e.partner_id = p.partner_id
|
||||||
|
WHERE la.calendar_year = ? AND la.company_id = ?`,
|
||||||
|
[year, year, month, year, year, month, year, companyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
report_month: month,
|
||||||
|
report_year: year,
|
||||||
|
count: rows.length,
|
||||||
|
data: rows
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate OB/CB report:", error);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { success: false, error: error instanceof Error ? error.message : "Unknown error" };
|
||||||
|
}
|
||||||
|
};
|
||||||
62
lms-service/routes.ts
Normal file
62
lms-service/routes.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// lms-service/routes.ts
|
||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { requireAuth, requireRole, AppRole } from "../shared/auth.ts";
|
||||||
|
import { applyForLeave } from "./controllers/leave.controller.ts";
|
||||||
|
import {
|
||||||
|
getPendingManagerLeaves,
|
||||||
|
approveLeaveManager,
|
||||||
|
rejectLeaveManager
|
||||||
|
} from "./controllers/manager.controller.ts";
|
||||||
|
import {
|
||||||
|
getLeaveBalances,
|
||||||
|
getLeaveHistory,
|
||||||
|
getValidOptionalHolidays
|
||||||
|
} from "./controllers/employee.controller.ts";
|
||||||
|
import {
|
||||||
|
createLeaveType,
|
||||||
|
getLeaveTypes,
|
||||||
|
createPolicyRule,
|
||||||
|
createCompanyHoliday,
|
||||||
|
updateWorkSettings
|
||||||
|
} from "./controllers/admin.controller.ts";
|
||||||
|
import { getLedgerReport } from "./controllers/reports.controller.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
const apiV1 = new Router();
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. Employee Leave Actions (All Staff)
|
||||||
|
// ==========================================
|
||||||
|
// Core application endpoint (Handles auto-splits, LOP, and sandwich logic)
|
||||||
|
apiV1.post("/lms/leaves/apply", requireAuth, applyForLeave);
|
||||||
|
|
||||||
|
// Employee dashboard endpoints
|
||||||
|
apiV1.get("/lms/leaves/balances", requireAuth, getLeaveBalances);
|
||||||
|
apiV1.get("/lms/leaves/history", requireAuth, getLeaveHistory);
|
||||||
|
apiV1.get("/lms/holidays/valid-optional", requireAuth, getValidOptionalHolidays);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. Manager Actions
|
||||||
|
// ==========================================
|
||||||
|
apiV1.get("/lms/manager/pending", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), getPendingManagerLeaves);
|
||||||
|
apiV1.post("/lms/manager/leaves/:applicationId/approve", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), approveLeaveManager);
|
||||||
|
apiV1.post("/lms/manager/leaves/:applicationId/reject", requireAuth, requireRole([AppRole.MANAGER, AppRole.ADMIN, AppRole.SUPER_ADMIN]), rejectLeaveManager);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. Admin Setup & Configuration
|
||||||
|
// ==========================================
|
||||||
|
apiV1.post("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createLeaveType);
|
||||||
|
apiV1.get("/lms/config/types", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLeaveTypes);
|
||||||
|
apiV1.post("/lms/config/rules", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createPolicyRule);
|
||||||
|
apiV1.post("/lms/config/holidays", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), createCompanyHoliday);
|
||||||
|
apiV1.put("/lms/config/work-settings", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), updateWorkSettings);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. Admin / HR Reporting
|
||||||
|
// ==========================================
|
||||||
|
// Upcoming endpoints to build:
|
||||||
|
apiV1.get("/lms/admin/reports/ob-cb", requireAuth, requireRole([AppRole.ADMIN, AppRole.SUPER_ADMIN]), getLedgerReport);
|
||||||
|
// router.post("/api/v1/lms/admin/leaves/:applicationId/approve", approveLeaveAdmin);
|
||||||
|
|
||||||
|
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
||||||
|
export default router;
|
||||||
@ -1,66 +0,0 @@
|
|||||||
// packages/core/auth.ts
|
|
||||||
import { Context, Next } from "@oak/oak";
|
|
||||||
|
|
||||||
export enum AppRole {
|
|
||||||
DIRECTOR = "DIRECTOR",
|
|
||||||
HR_MANAGER = "HR_MANAGER",
|
|
||||||
MANAGER = "MANAGER",
|
|
||||||
EMPLOYEE = "EMPLOYEE",
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Authentication & Internal Security Middleware
|
|
||||||
export const requireAuth = async (ctx: Context, next: Next) => {
|
|
||||||
const internalToken = ctx.request.headers.get("X-Internal-Token");
|
|
||||||
const expectedToken = Deno.env.get("INTERNAL_SERVICE_TOKEN");
|
|
||||||
|
|
||||||
if (!expectedToken || internalToken !== expectedToken) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = { success: false, message: "Forbidden: Direct access to microservices is not allowed." };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = ctx.request.headers.get("X-User-Id");
|
|
||||||
const userRoleHeader = ctx.request.headers.get("X-User-Role") as string;
|
|
||||||
const managedBranchesHeader = ctx.request.headers.get("X-Managed-Branches");
|
|
||||||
|
|
||||||
if (!userId || !userRoleHeader) {
|
|
||||||
ctx.response.status = 401;
|
|
||||||
ctx.response.body = { success: false, message: "Unauthorized: Missing user context" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map string header to Enum (with fallback for backward compatibility)
|
|
||||||
let userRole: AppRole;
|
|
||||||
if (userRoleHeader === "DIRECTOR" || userRoleHeader === "SUPER_ADMIN") userRole = AppRole.DIRECTOR;
|
|
||||||
else if (userRoleHeader === "HR_MANAGER" || userRoleHeader === "ADMIN") userRole = AppRole.HR_MANAGER;
|
|
||||||
else if (userRoleHeader === "MANAGER") userRole = AppRole.MANAGER;
|
|
||||||
else userRole = AppRole.EMPLOYEE;
|
|
||||||
|
|
||||||
const managedBranches = managedBranchesHeader
|
|
||||||
? managedBranchesHeader.split(",").map(Number).filter(n => !isNaN(n))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
ctx.state.user = {
|
|
||||||
employee_id: Number(userId),
|
|
||||||
role: userRole,
|
|
||||||
managed_branches: managedBranches
|
|
||||||
};
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Authorization Middleware
|
|
||||||
export const requireRole = (allowedRoles: AppRole[]) => {
|
|
||||||
return async (ctx: Context, next: Next) => {
|
|
||||||
const userRole = ctx.state.user?.role as AppRole;
|
|
||||||
if (!userRole || !allowedRoles.includes(userRole)) {
|
|
||||||
ctx.response.status = 403;
|
|
||||||
ctx.response.body = {
|
|
||||||
success: false,
|
|
||||||
message: "Access Denied: You do not have the required permissions."
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@shared/utils",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"exports": {
|
|
||||||
"./auth": "./auth.ts",
|
|
||||||
"./internal-client": "./internal-client.ts",
|
|
||||||
"./calendar": "./calendar.service.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
// packages/core/internal-client.ts
|
|
||||||
|
|
||||||
const EMS_BASE_URL = Deno.env.get("EMS_URL") || "http://localhost:8001";
|
|
||||||
const INTERNAL_TOKEN = Deno.env.get("INTERNAL_SERVICE_TOKEN") || "";
|
|
||||||
|
|
||||||
const getInternalHeaders = () => ({
|
|
||||||
"X-Internal-Token": INTERNAL_TOKEN,
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the manager ID for a specific employee from EMS.
|
|
||||||
*/
|
|
||||||
export const getEmployeeManagerId = async (employeeId: number): Promise<number | null> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${EMS_BASE_URL}/internal/employees/${employeeId}/manager`, {
|
|
||||||
headers: getInternalHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const json = await response.json();
|
|
||||||
return json.data?.manager_id || null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch manager from EMS:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a roster of active employees from EMS.
|
|
||||||
*/
|
|
||||||
export const getEmployeeRoster = async (ids: number[] = []): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
let url = `${EMS_BASE_URL}/internal/employees/roster`;
|
|
||||||
if (ids.length > 0) {
|
|
||||||
url += `?ids=${ids.join(",")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: getInternalHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const json = await response.json();
|
|
||||||
return json.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch roster from EMS:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
// services/ams/controllers/attendance.controller.ts
|
|
||||||
import * as AttendanceService from "../services/attendance.service.ts";
|
|
||||||
|
|
||||||
export const getRawLogs = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await AttendanceService.getRawLogs();
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processDailyAttendance = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
let body = {};
|
|
||||||
if (ctx.request.hasBody) {
|
|
||||||
body = await ctx.request.body.json();
|
|
||||||
}
|
|
||||||
const result = await AttendanceService.processDailyAttendance(body);
|
|
||||||
ctx.response.body = { success: true, message: `Successfully processed ${result.processed} logs across ${result.days} days.` };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeSummary = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const employeeId = Number(params.get("employee_id"));
|
|
||||||
const startDate = params.get("start_date");
|
|
||||||
const endDate = params.get("end_date");
|
|
||||||
if (!employeeId || !startDate || !endDate) throw new Error("Missing required query parameters.");
|
|
||||||
const data = await AttendanceService.getEmployeeSummary(employeeId, startDate, endDate);
|
|
||||||
ctx.response.body = { success: true, ...data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDailyReport = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const targetDate = ctx.request.url.searchParams.get("date");
|
|
||||||
if (!targetDate) throw new Error("Missing required 'date' query parameter.");
|
|
||||||
const data = await AttendanceService.getDailyReport(targetDate);
|
|
||||||
ctx.response.body = { success: true, ...data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminRangeReport = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const data = await AttendanceService.getAdminRangeReport(
|
|
||||||
params.get("from_date"), params.get("to_date"), params.get("branch_id"), params.get("department_id")
|
|
||||||
);
|
|
||||||
ctx.response.body = { success: true, ...data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSingleEmployeeRangeReport = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const params = ctx.request.url.searchParams;
|
|
||||||
const data = await AttendanceService.getSingleEmployeeRangeReport(
|
|
||||||
Number(params.get("employee_id")), params.get("from_date"), params.get("to_date")
|
|
||||||
);
|
|
||||||
ctx.response.body = { success: true, ...data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
// services/ams/controllers/regularization.controller.ts
|
|
||||||
import * as RegService from "../services/regularization.service.ts";
|
|
||||||
|
|
||||||
const getJsonBody = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
return await ctx.request.body.json();
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error("Invalid JSON body. Check Postman variables and raw JSON format.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createRegularizationRequest = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await RegService.createRegularizationRequest(body);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Regularization request submitted successfully." };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPendingRegularizations = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await RegService.getPendingRegularizations(ctx.state.user);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reviewRegularizationRequest = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await RegService.reviewRegularizationRequest(ctx.state.user, body);
|
|
||||||
ctx.response.body = { success: true, message: `Request has been successfully processed.` };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("Access Denied")) ctx.response.status = 403;
|
|
||||||
else if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
// services/ams/db.ts
|
|
||||||
import mysql from "mysql2/promise";
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
|
||||||
port: Number(Deno.env.get("AMS_DB_PORT")) || 3307,
|
|
||||||
user: Deno.env.get("DB_USER") || "admin",
|
|
||||||
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
|
||||||
database: "hrms_ams",
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default pool;
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
// services/ams/repositories/attendance.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const findRawLogs = async () => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
"SELECT * FROM attendance_raw_logs ORDER BY attendance_time DESC LIMIT 20"
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findDistinctRawDates = async () => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT DISTINCT DATE_FORMAT(attendance_time, '%Y-%m-%d') as raw_date FROM attendance_raw_logs ORDER BY raw_date ASC`
|
|
||||||
);
|
|
||||||
return rows.map((r: any) => {
|
|
||||||
if (r.raw_date instanceof Date) {
|
|
||||||
const offset = r.raw_date.getTimezoneOffset();
|
|
||||||
const localDate = new Date(r.raw_date.getTime() - (offset * 60 * 1000));
|
|
||||||
return localDate.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
return String(r.raw_date);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findRawLogsByDate = async (targetDate: string) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT employee_code, DATE_FORMAT(attendance_time, '%Y-%m-%d %H:%i:%s') as att_time
|
|
||||||
FROM attendance_raw_logs
|
|
||||||
WHERE DATE(attendance_time) = ?
|
|
||||||
ORDER BY employee_code, attendance_time ASC`,
|
|
||||||
[targetDate]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertProcessedAttendance = async (data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO processed_daily_attendance
|
|
||||||
(employee_id, company_id, branch_id, work_date, check_in, check_out, worked_hours, check_in_status, final_status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
check_in = VALUES(check_in),
|
|
||||||
check_out = VALUES(check_out),
|
|
||||||
worked_hours = VALUES(worked_hours),
|
|
||||||
check_in_status = VALUES(check_in_status),
|
|
||||||
final_status = VALUES(final_status)`,
|
|
||||||
[data.employeeId, data.company_id, data.branch_id, data.targetDate, data.check_in, data.check_out, data.worked_hours, data.check_in_status, data.final_status]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT
|
|
||||||
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
|
||||||
ORDER BY work_date DESC`,
|
|
||||||
[employeeId, startDate, endDate]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findDailyAttendance = async (targetDate: string) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT employee_id, attendance_id,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in,
|
|
||||||
DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE work_date = ?`,
|
|
||||||
[targetDate]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findAttendanceByEmployeeIds = async (employeeIds: number[], fromDate: string, toDate: string) => {
|
|
||||||
if (employeeIds.length === 0) return [];
|
|
||||||
const placeholders = employeeIds.map(() => "?").join(",");
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT
|
|
||||||
employee_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE work_date BETWEEN ? AND ? AND employee_id IN (${placeholders})
|
|
||||||
ORDER BY work_date ASC`,
|
|
||||||
[fromDate, toDate, ...employeeIds]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findSingleEmployeeAttendance = async (employeeId: number, fromDate: string, toDate: string) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT
|
|
||||||
attendance_id, DATE_FORMAT(work_date, '%Y-%m-%d') as work_date,
|
|
||||||
DATE_FORMAT(check_in, '%H:%i:%s') as check_in, DATE_FORMAT(check_out, '%H:%i:%s') as check_out,
|
|
||||||
worked_hours, check_in_status, final_status
|
|
||||||
FROM processed_daily_attendance
|
|
||||||
WHERE employee_id = ? AND work_date BETWEEN ? AND ?
|
|
||||||
ORDER BY work_date ASC`,
|
|
||||||
[employeeId, fromDate, toDate]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findShiftDetails = async (branchId: number, companyId: number, conn?: any) => {
|
|
||||||
const execute = conn ? conn.execute : pool.execute;
|
|
||||||
const [rows]: any = await 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`,
|
|
||||||
[branchId, companyId]
|
|
||||||
);
|
|
||||||
return rows[0] || { start_time: "10:00:00", grace_period_minutes: 10 };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateProcessedAttendanceById = async (attendanceId: number, data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE processed_daily_attendance
|
|
||||||
SET check_in = ?, check_out = ?, worked_hours = ?, final_status = ?, check_in_status = ?
|
|
||||||
WHERE attendance_id = ?`,
|
|
||||||
[data.requested_check_in, data.requested_check_out, data.worked_hours, data.final_status, data.check_in_status, attendanceId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
// services/ams/repositories/regularization.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const insertRegularization = async (data: any) => {
|
|
||||||
await pool.execute(
|
|
||||||
`INSERT INTO attendance_regularizations
|
|
||||||
(attendance_id, employee_id, branch_id, regularization_type, target_date, requested_check_in, requested_check_out, reason, status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')`,
|
|
||||||
[data.attendance_id || null, data.employee_id, data.branch_id, data.regularization_type, data.target_date, data.requested_check_in || null, data.requested_check_out || null, data.reason]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPendingRegularizations = async (branchIds: number[] | null) => {
|
|
||||||
let query = `SELECT regularization_id, employee_id, target_date, regularization_type, requested_check_in, requested_check_out, reason, status, branch_id FROM attendance_regularizations WHERE status = 'PENDING'`;
|
|
||||||
const params: any[] = [];
|
|
||||||
|
|
||||||
if (branchIds && branchIds.length > 0) {
|
|
||||||
const placeholders = branchIds.map(() => "?").join(",");
|
|
||||||
query += ` AND branch_id IN (${placeholders})`;
|
|
||||||
params.push(...branchIds);
|
|
||||||
}
|
|
||||||
query += ` ORDER BY target_date DESC`;
|
|
||||||
|
|
||||||
const [rows] = await pool.query(query, params);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findRegularizationById = async (id: number, conn: any) => {
|
|
||||||
const [rows]: any = await conn.execute(
|
|
||||||
`SELECT regularization_id, attendance_id, branch_id, employee_id, requested_check_in, requested_check_out, status
|
|
||||||
FROM attendance_regularizations WHERE regularization_id = ?`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateRegularizationStatus = async (id: number, status: string, reviewerId: number, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE attendance_regularizations SET status = ?, reviewed_by_id = ? WHERE regularization_id = ?`,
|
|
||||||
[status, reviewerId, id]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
// services/ams/routes.ts
|
|
||||||
import { Router } from "@oak/oak";
|
|
||||||
import { requireAuth, requireRole, AppRole } from "core/auth.ts";
|
|
||||||
|
|
||||||
import * as AttCtrl from "./controllers/attendance.controller.ts";
|
|
||||||
import * as RegCtrl from "./controllers/regularization.controller.ts";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
router.get("/attendance/logs", AttCtrl.getRawLogs);
|
|
||||||
router.post("/attendance/process-daily", AttCtrl.processDailyAttendance);
|
|
||||||
|
|
||||||
// Regularization Routes
|
|
||||||
router.post("/attendance/regularize", requireAuth, requireRole([AppRole.EMPLOYEE, AppRole.HR_MANAGER]), RegCtrl.createRegularizationRequest);
|
|
||||||
router.get("/attendance/regularize/pending", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), RegCtrl.getPendingRegularizations);
|
|
||||||
router.post("/attendance/regularize/review", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), RegCtrl.reviewRegularizationRequest);
|
|
||||||
|
|
||||||
router.get("/attendance/my-summary", AttCtrl.getEmployeeSummary);
|
|
||||||
router.get("/attendance/daily-report", AttCtrl.getDailyReport);
|
|
||||||
router.get("/attendance/admin-report", AttCtrl.getAdminRangeReport);
|
|
||||||
router.get("/attendance/employee-range-report", AttCtrl.getSingleEmployeeRangeReport);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
// services/ams/services/attendance.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { getEmployeeRoster } from "core/internal-client.ts";
|
|
||||||
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
|
|
||||||
|
|
||||||
const getDaysArray = (start: string, end: string): string[] => {
|
|
||||||
const arr: string[] = [];
|
|
||||||
const dt = new Date(start);
|
|
||||||
const endDt = new Date(end);
|
|
||||||
while (dt <= endDt) {
|
|
||||||
arr.push(new Date(dt).toISOString().split('T')[0]);
|
|
||||||
dt.setDate(dt.getDate() + 1);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRawLogs = async () => {
|
|
||||||
return await AttendanceRepo.findRawLogs();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const processDailyAttendance = async (body: any) => {
|
|
||||||
let datesToProcess: string[] = [];
|
|
||||||
|
|
||||||
if (body.work_date) {
|
|
||||||
datesToProcess.push(body.work_date);
|
|
||||||
} else if (body.start_date && body.end_date) {
|
|
||||||
datesToProcess = getDaysArray(body.start_date, body.end_date);
|
|
||||||
} else {
|
|
||||||
datesToProcess = await AttendanceRepo.findDistinctRawDates();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (datesToProcess.length === 0) throw new Error("No dates found to process.");
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
let totalProcessedRecords = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const employeeRoster = await getEmployeeRoster();
|
|
||||||
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employee_code, e]));
|
|
||||||
|
|
||||||
for (const targetDate of datesToProcess) {
|
|
||||||
const rawLogs = await AttendanceRepo.findRawLogsByDate(targetDate);
|
|
||||||
if (rawLogs.length === 0) continue;
|
|
||||||
|
|
||||||
const groupedLogs = rawLogs.reduce((acc: any, log: any) => {
|
|
||||||
if (!acc[log.employee_code]) acc[log.employee_code] = [];
|
|
||||||
acc[log.employee_code].push(log.att_time);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
for (const empCode in groupedLogs) {
|
|
||||||
const employee = employeeMap.get(empCode);
|
|
||||||
if (!employee || !employee.is_active) continue;
|
|
||||||
|
|
||||||
const punches = groupedLogs[empCode];
|
|
||||||
const shift = await AttendanceRepo.findShiftDetails(employee.branch_id, employee.company_id, connection);
|
|
||||||
|
|
||||||
let check_in = null, check_out = null, worked_hours = 0.0;
|
|
||||||
let check_in_status = "ON_TIME", final_status = "ABSENT";
|
|
||||||
|
|
||||||
if (punches.length >= 2) {
|
|
||||||
check_in = punches[0];
|
|
||||||
check_out = punches[punches.length - 1];
|
|
||||||
const firstPunchMs = new Date(check_in.replace(' ', 'T')).getTime();
|
|
||||||
const lastPunchMs = new Date(check_out.replace(' ', 'T')).getTime();
|
|
||||||
worked_hours = Math.round(((lastPunchMs - firstPunchMs) / (1000 * 60 * 60)) * 100) / 100;
|
|
||||||
|
|
||||||
const rawCheckInTimeStr = check_in.split(' ')[1];
|
|
||||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(':').map(Number);
|
|
||||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(':').map(Number);
|
|
||||||
|
|
||||||
if ((punchH * 3600 + punchM * 60 + punchS) > (shiftH * 3600 + shiftM * 60 + shift.grace_period_minutes * 60)) {
|
|
||||||
check_in_status = "LATE";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
|
||||||
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
|
||||||
else final_status = "ABSENT";
|
|
||||||
} else if (punches.length === 1) {
|
|
||||||
check_in = punches[0];
|
|
||||||
check_in_status = "MISPUNCH";
|
|
||||||
final_status = "MISPUNCH";
|
|
||||||
}
|
|
||||||
|
|
||||||
await AttendanceRepo.upsertProcessedAttendance({
|
|
||||||
employeeId: employee.employee_id,
|
|
||||||
company_id: employee.company_id,
|
|
||||||
branch_id: employee.branch_id,
|
|
||||||
targetDate, check_in, check_out, worked_hours, check_in_status, final_status
|
|
||||||
}, connection);
|
|
||||||
|
|
||||||
totalProcessedRecords++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await connection.commit();
|
|
||||||
return { processed: totalProcessedRecords, days: datesToProcess.length };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeSummary = async (employeeId: number, startDate: string, endDate: string) => {
|
|
||||||
const rows = await AttendanceRepo.findEmployeeSummary(employeeId, startDate, endDate);
|
|
||||||
|
|
||||||
const summaryMetrics = rows.reduce((acc: any, row: any) => {
|
|
||||||
acc.total_days_tracked++;
|
|
||||||
acc.total_hours_worked += Number(row.worked_hours || 0);
|
|
||||||
if (row.final_status === "FULL_DAY") acc.full_days++;
|
|
||||||
else if (row.final_status === "HALF_DAY") acc.half_days++;
|
|
||||||
else if (row.final_status === "ABSENT") acc.absences++;
|
|
||||||
else if (row.final_status === "MISPUNCH") acc.mispunches++;
|
|
||||||
if (row.check_in_status === "LATE") acc.late_arrivals++;
|
|
||||||
return acc;
|
|
||||||
}, {
|
|
||||||
total_days_tracked: 0, total_hours_worked: 0, full_days: 0, half_days: 0, absences: 0, mispunches: 0, late_arrivals: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
summaryMetrics.total_hours_worked = Math.round(summaryMetrics.total_hours_worked * 100) / 100;
|
|
||||||
return { metrics: summaryMetrics, history: rows };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDailyReport = async (targetDate: string) => {
|
|
||||||
const attendanceRows = await AttendanceRepo.findDailyAttendance(targetDate);
|
|
||||||
const employeeRoster = await getEmployeeRoster();
|
|
||||||
const employeeMap = new Map(employeeRoster.map((e: any) => [e.employee_id, e]));
|
|
||||||
|
|
||||||
const summary = { total_active_workforce: employeeRoster.length, present: 0, late: 0, mispunches: 0, absent: 0 };
|
|
||||||
|
|
||||||
const roster = employeeRoster.map((emp: any) => {
|
|
||||||
const attendance = attendanceRows.find((a: any) => a.employee_id === emp.employee_id);
|
|
||||||
let status = "ABSENT", check_in = null, check_out = null, worked_hours = "0.00", punctuality = "N/A", attendance_id = null;
|
|
||||||
|
|
||||||
if (attendance) {
|
|
||||||
status = attendance.final_status;
|
|
||||||
check_in = attendance.check_in;
|
|
||||||
check_out = attendance.check_out;
|
|
||||||
worked_hours = attendance.worked_hours;
|
|
||||||
punctuality = attendance.check_in_status;
|
|
||||||
attendance_id = attendance.attendance_id;
|
|
||||||
if (status === "FULL_DAY" || status === "HALF_DAY") summary.present++;
|
|
||||||
if (status === "MISPUNCH") summary.mispunches++;
|
|
||||||
if (punctuality === "LATE") summary.late++;
|
|
||||||
} else {
|
|
||||||
summary.absent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee_id: emp.employee_id, employee_code: emp.employee_code, full_name: emp.full_name,
|
|
||||||
attendance_id, status, check_in, check_out, worked_hours, punctuality
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { date: targetDate, summary, roster };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAdminRangeReport = async (fromDate: string, toDate: string, branchId?: string, departmentId?: string) => {
|
|
||||||
let employeeRoster = await getEmployeeRoster();
|
|
||||||
if (branchId) employeeRoster = employeeRoster.filter((e: any) => e.branch_id === Number(branchId));
|
|
||||||
if (departmentId) employeeRoster = employeeRoster.filter((e: any) => e.department_id === Number(departmentId));
|
|
||||||
|
|
||||||
if (employeeRoster.length === 0) return { summary: {}, report: [] };
|
|
||||||
|
|
||||||
const employeeIds = employeeRoster.map((e: any) => e.employee_id);
|
|
||||||
const attendanceRows = await AttendanceRepo.findAttendanceByEmployeeIds(employeeIds, fromDate, toDate);
|
|
||||||
|
|
||||||
const attendanceGrouped = attendanceRows.reduce((acc: any, row: any) => {
|
|
||||||
if (!acc[row.employee_id]) acc[row.employee_id] = [];
|
|
||||||
acc[row.employee_id].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const globalSummary = { total_records_evaluated: attendanceRows.length, full_days: 0, half_days: 0, late_instances: 0, mispunches: 0 };
|
|
||||||
|
|
||||||
const report = employeeRoster.map((emp: any) => {
|
|
||||||
const logs = attendanceGrouped[emp.employee_id] || [];
|
|
||||||
const individualMetrics = logs.reduce((acc: any, log: any) => {
|
|
||||||
if (log.final_status === "FULL_DAY") { acc.full_days++; globalSummary.full_days++; }
|
|
||||||
else if (log.final_status === "HALF_DAY") { acc.half_days++; globalSummary.half_days++; }
|
|
||||||
else if (log.final_status === "MISPUNCH") { acc.mispunches++; globalSummary.mispunches++; }
|
|
||||||
if (log.check_in_status === "LATE") { acc.late_arrivals++; globalSummary.late_instances++; }
|
|
||||||
return acc;
|
|
||||||
}, { full_days: 0, half_days: 0, mispunches: 0, late_arrivals: 0 });
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee_id: emp.employee_id, employee_code: emp.employee_code,
|
|
||||||
first_name: emp.full_name.split(" ")[0], last_name: emp.full_name.split(" ").slice(1).join(" "),
|
|
||||||
branch_name: emp.branch_name, department_name: emp.department_name,
|
|
||||||
range_metrics: individualMetrics, attendance_history: logs
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { date_range: { from: fromDate, to: toDate }, global_summary: globalSummary, report };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSingleEmployeeRangeReport = async (employeeId: number, fromDate: string, toDate: string) => {
|
|
||||||
const roster = await getEmployeeRoster([employeeId]);
|
|
||||||
const empProfile = roster[0];
|
|
||||||
if (!empProfile) throw new Error("Employee profile not found.");
|
|
||||||
|
|
||||||
const logs = await AttendanceRepo.findSingleEmployeeAttendance(employeeId, fromDate, toDate);
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee: {
|
|
||||||
employee_id: empProfile.employee_id, employee_code: empProfile.employee_code,
|
|
||||||
first_name: empProfile.full_name.split(" ")[0], last_name: empProfile.full_name.split(" ").slice(1).join(" ")
|
|
||||||
},
|
|
||||||
range: { from: fromDate, to: toDate },
|
|
||||||
total_days: logs.length,
|
|
||||||
history: logs
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
// services/ams/services/regularization.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { AppRole } from "core/auth.ts";
|
|
||||||
import { getEmployeeRoster } from "core/internal-client.ts";
|
|
||||||
import * as RegRepo from "../repositories/regularization.repository.ts";
|
|
||||||
import * as AttendanceRepo from "../repositories/attendance.repository.ts";
|
|
||||||
|
|
||||||
export const createRegularizationRequest = async (data: any) => {
|
|
||||||
if (!data.employee_id || !data.reason || !data.regularization_type || !data.target_date) {
|
|
||||||
throw new Error("Missing required fields.");
|
|
||||||
}
|
|
||||||
const roster = await getEmployeeRoster([data.employee_id]);
|
|
||||||
if (roster.length === 0) throw new Error("Employee not found in EMS.");
|
|
||||||
|
|
||||||
const branch_id = roster[0].branch_id;
|
|
||||||
await RegRepo.insertRegularization({ ...data, branch_id });
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPendingRegularizations = async (user: any) => {
|
|
||||||
let branchIds: number[] | null = null;
|
|
||||||
if (user.role !== AppRole.DIRECTOR) {
|
|
||||||
branchIds = user.managed_branches;
|
|
||||||
if (!branchIds || branchIds.length === 0) return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await RegRepo.findPendingRegularizations(branchIds);
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
|
|
||||||
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
|
|
||||||
const roster = await getEmployeeRoster(employeeIds);
|
|
||||||
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
|
|
||||||
|
|
||||||
return rows.map((req: any) => {
|
|
||||||
const emp = rosterMap.get(req.employee_id);
|
|
||||||
return {
|
|
||||||
...req,
|
|
||||||
employee_code: emp?.employee_code || "N/A",
|
|
||||||
first_name: emp?.full_name?.split(" ")[0] || "",
|
|
||||||
last_name: emp?.full_name?.split(" ").slice(1).join(" ") || "",
|
|
||||||
branch_name: emp?.branch_name || "N/A",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reviewRegularizationRequest = async (user: any, data: any) => {
|
|
||||||
if (!data.regularization_id || !["APPROVED", "REJECTED"].includes(data.action)) {
|
|
||||||
throw new Error("Invalid payload.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const request = await RegRepo.findRegularizationById(data.regularization_id, connection);
|
|
||||||
|
|
||||||
if (!request) throw new Error("Regularization request not found.");
|
|
||||||
if (user.role !== AppRole.DIRECTOR && !user.managed_branches.includes(request.branch_id)) {
|
|
||||||
throw new Error("Access Denied: You do not manage this branch.");
|
|
||||||
}
|
|
||||||
if (request.status !== "PENDING") throw new Error("This request has already been processed.");
|
|
||||||
|
|
||||||
await RegRepo.updateRegularizationStatus(data.regularization_id, data.action, user.employee_id, connection);
|
|
||||||
|
|
||||||
if (data.action === "APPROVED") {
|
|
||||||
let worked_hours = 0.0, final_status = "FULL_DAY", check_in_status = "ON_TIME";
|
|
||||||
|
|
||||||
const roster = await getEmployeeRoster([request.employee_id]);
|
|
||||||
const emp = roster[0];
|
|
||||||
|
|
||||||
if (emp) {
|
|
||||||
const shift = await AttendanceRepo.findShiftDetails(emp.branch_id, emp.company_id, connection);
|
|
||||||
if (request.requested_check_in && request.requested_check_out) {
|
|
||||||
const checkInMs = new Date(request.requested_check_in.replace(" ", "T")).getTime();
|
|
||||||
const checkOutMs = new Date(request.requested_check_out.replace(" ", "T")).getTime();
|
|
||||||
const effectiveCheckOutMs = checkOutMs < checkInMs ? checkOutMs + (24 * 60 * 60 * 1000) : checkOutMs;
|
|
||||||
worked_hours = Math.round(((effectiveCheckOutMs - checkInMs) / (1000 * 60 * 60)) * 100) / 100;
|
|
||||||
|
|
||||||
if (worked_hours >= 7.0) final_status = "FULL_DAY";
|
|
||||||
else if (worked_hours >= 4.0) final_status = "HALF_DAY";
|
|
||||||
else final_status = "ABSENT";
|
|
||||||
|
|
||||||
const rawCheckInTimeStr = request.requested_check_in.split(" ")[1];
|
|
||||||
const [punchH, punchM, punchS] = rawCheckInTimeStr.split(":").map(Number);
|
|
||||||
const [shiftH, shiftM, shiftS] = shift.start_time.split(":").map(Number);
|
|
||||||
|
|
||||||
const punchTotalSeconds = punchH * 3600 + punchM * 60 + punchS;
|
|
||||||
const shiftCutoffSeconds = shiftH * 3600 + shiftM * 60 + (shift.grace_period_minutes * 60);
|
|
||||||
|
|
||||||
if (punchTotalSeconds > shiftCutoffSeconds) {
|
|
||||||
check_in_status = "LATE";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.attendance_id) {
|
|
||||||
await AttendanceRepo.updateProcessedAttendanceById(request.attendance_id, {
|
|
||||||
requested_check_in: request.requested_check_in,
|
|
||||||
requested_check_out: request.requested_check_out,
|
|
||||||
worked_hours, final_status, check_in_status
|
|
||||||
}, connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
// services/ems/controllers/contract.controller.ts
|
|
||||||
import * as ContractService from "../services/contract.service.ts";
|
|
||||||
|
|
||||||
export const getEmployeeContracts = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await ContractService.getEmployeeContracts(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createContract = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const result = await ContractService.createContract(body);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "New assignment executed successfully. Previous assignments expired.", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
// services/ems/controllers/dashboard.controller.ts
|
|
||||||
import * as DashboardService from "../services/dashboard.service.ts";
|
|
||||||
|
|
||||||
export const getDashboardMetrics = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await DashboardService.getDashboardMetrics(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
// services/ems/controllers/employee.controller.ts
|
|
||||||
import * as EmployeeService from "../services/employee.service.ts";
|
|
||||||
|
|
||||||
// Helper for parsing JSON body in Oak v17
|
|
||||||
const getJsonBody = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
// FIX: Oak v17 ctx.request.body is an object with a .json() method
|
|
||||||
return await ctx.request.body.json();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Body parse error:", e);
|
|
||||||
throw new Error("Invalid JSON body. Make sure you are sending raw JSON.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployees = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getEmployees(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeById = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getEmployeeById(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeHistory = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getEmployeeHistory(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, history: data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createEmployee = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const result = await EmployeeService.createEmployee(body);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Employee created", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("already exists")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEmployee = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await EmployeeService.updateEmployee(Number(ctx.params.id), body);
|
|
||||||
ctx.response.body = { success: true, message: "Employee updated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("already belongs")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEmployee = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
await EmployeeService.deleteEmployee(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, message: "Employee deactivated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 404;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
// services/ems/controllers/internal.controller.ts
|
|
||||||
import * as InternalService from "../services/internal.service.ts";
|
|
||||||
|
|
||||||
export const getInternalEmployeeRoster = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await InternalService.getRoster(ctx.request.url.searchParams.get("ids"));
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getInternalManager = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await InternalService.getManager(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 404; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
// services/ems/controllers/lookup.controller.ts
|
|
||||||
import * as LookupService from "../services/lookup.service.ts";
|
|
||||||
|
|
||||||
// Helper for parsing JSON body in Oak v17
|
|
||||||
const getJsonBody = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
// FIX: Oak v17 ctx.request.body is an object with a .json() method
|
|
||||||
return await ctx.request.body.json();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Body parse error:", e);
|
|
||||||
throw new Error("Invalid JSON body. Make sure you are sending raw JSON.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// COMPANIES
|
|
||||||
// ==========================================
|
|
||||||
export const getCompanies = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await LookupService.getCompanies(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCompany = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const result = await LookupService.createCompany(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Company created", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("already exists")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCompany = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await LookupService.updateCompany(Number(ctx.params.id), body);
|
|
||||||
ctx.response.body = { success: true, message: "Company updated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCompany = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
await LookupService.deleteCompany(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, message: "Company deleted successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// BRANCHES
|
|
||||||
// ==========================================
|
|
||||||
export const getBranches = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await LookupService.getBranches(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createBranch = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const result = await LookupService.createBranch(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Branch created successfully", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("already exists")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateBranch = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await LookupService.updateBranch(Number(ctx.params.id), body);
|
|
||||||
ctx.response.body = { success: true, message: "Branch updated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBranch = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
await LookupService.deleteBranch(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, message: "Branch deleted successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// DEPARTMENTS
|
|
||||||
// ==========================================
|
|
||||||
export const getDepartments = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await LookupService.getDepartments(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDepartment = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const result = await LookupService.createDepartment(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Department created", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("already exists")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("required")) ctx.response.status = 400;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDepartment = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await LookupService.updateDepartment(Number(ctx.params.id), body);
|
|
||||||
ctx.response.body = { success: true, message: "Department updated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDepartment = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
await LookupService.deleteDepartment(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, message: "Department deleted successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// JOBS
|
|
||||||
// ==========================================
|
|
||||||
export const getJobs = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await LookupService.getJobs(ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createJob = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const result = await LookupService.createJob(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Job created", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("already exists")) ctx.response.status = 409;
|
|
||||||
else if (error.message.includes("required")) ctx.response.status = 400;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateJob = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await LookupService.updateJob(Number(ctx.params.id), body);
|
|
||||||
ctx.response.body = { success: true, message: "Job updated successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Invalid JSON")) ctx.response.status = 400;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteJob = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
await LookupService.deleteJob(Number(ctx.params.id));
|
|
||||||
ctx.response.body = { success: true, message: "Job deleted successfully" };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else if (error.message.includes("Cannot delete")) ctx.response.status = 409;
|
|
||||||
else ctx.response.status = 500;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
// services/ems/controllers/system.controller.ts
|
|
||||||
import * as SystemService from "../services/system.service.ts";
|
|
||||||
|
|
||||||
export const bulkSeedEmployees = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const result = await SystemService.bulkSeedEmployees(body);
|
|
||||||
ctx.response.body = { success: true, message: `Successfully seeded/aligned ${result.count} normalized employee profiles.` };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkMapHierarchy = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await ctx.request.body.json();
|
|
||||||
const metrics = await SystemService.bulkMapHierarchy(body);
|
|
||||||
ctx.response.body = { success: true, message: "Hierarchy mapping completed successfully.", metrics };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: "Invalid JSON body or internal error." };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
// services/ems/db.ts
|
|
||||||
import mysql from "mysql2/promise";
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
|
||||||
port: Number(Deno.env.get("EMS_DB_PORT")) || 3306,
|
|
||||||
user: Deno.env.get("DB_USER") || "admin",
|
|
||||||
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
|
||||||
database: "hrms_ems",
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default pool;
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
// services/ems/repositories/contract.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const findContractsByEmployeeId = async (employeeId: number) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
et.term_id, et.work_email, et.date_joining, et.probation_days, et.status, et.salary_structure_id,
|
|
||||||
ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
|
|
||||||
d.name AS department, j.title AS designation
|
|
||||||
FROM employment_terms et
|
|
||||||
JOIN employee_assignments ea ON et.employee_id = ea.employee_id
|
|
||||||
JOIN departments d ON ea.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON ea.job_id = j.job_id
|
|
||||||
WHERE et.employee_id = ?
|
|
||||||
ORDER BY et.date_joining DESC, ea.effective_from DESC`,
|
|
||||||
[employeeId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expireCurrentAssignment = async (employeeId: number, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE employee_assignments SET is_current = FALSE, effective_to = CURDATE() WHERE employee_id = ? AND is_current = TRUE`,
|
|
||||||
[employeeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertNewAssignment = async (data: any, conn: any) => {
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
|
||||||
VALUES (?, ?, ?, ?, ?, CURDATE(), TRUE)`,
|
|
||||||
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.changeReason || 'TRANSFER']
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// services/ems/repositories/dashboard.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
const buildFilters = (filters: any) => {
|
|
||||||
let clause = " WHERE 1=1";
|
|
||||||
const values: any[] = [];
|
|
||||||
if (filters.company_id) { clause += " AND company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { clause += " AND branch_id = ?"; values.push(filters.branch_id); }
|
|
||||||
return { clause, values };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countCompanies = async () => {
|
|
||||||
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM companies`);
|
|
||||||
return rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countBranches = async (filters: any) => {
|
|
||||||
const { clause, values } = buildFilters(filters);
|
|
||||||
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM branches ${clause}`, values);
|
|
||||||
return rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countDepartments = async (filters: any) => {
|
|
||||||
const { clause, values } = buildFilters(filters);
|
|
||||||
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM departments ${clause}`, values);
|
|
||||||
return rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countJobs = async (filters: any) => {
|
|
||||||
let clause = " WHERE 1=1";
|
|
||||||
const values: any[] = [];
|
|
||||||
if (filters.company_id) { clause += " AND d.company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { clause += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
|
||||||
if (filters.department_id) { clause += " AND j.department_id = ?"; values.push(filters.department_id); }
|
|
||||||
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM job_positions j JOIN departments d ON j.department_id = d.department_id ${clause}`, values);
|
|
||||||
return rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const countEmployees = async (filters: any) => {
|
|
||||||
let clause = " WHERE e.is_active = TRUE";
|
|
||||||
const values: any[] = [];
|
|
||||||
if (filters.company_id) { clause += " AND e.company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { clause += " AND e.branch_id = ?"; values.push(filters.branch_id); }
|
|
||||||
if (filters.department_id) { clause += " AND ea.department_id = ?"; values.push(filters.department_id); }
|
|
||||||
const [rows]: any = await pool.query(`SELECT COUNT(*) as count FROM employees e JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE ${clause}`, values);
|
|
||||||
return rows[0].count;
|
|
||||||
};
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
// services/ems/repositories/employee.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const findEmployees = async (filters: any) => {
|
|
||||||
let query = `SELECT e.employee_id, e.employee_code, e.is_active,
|
|
||||||
CONCAT(p.first_name, ' ', p.last_name) AS full_name,
|
|
||||||
et.work_email, j.title AS job_name, d.name AS department_name,
|
|
||||||
b.branch_name, c.name AS company_name
|
|
||||||
FROM employees e
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
JOIN companies c ON e.company_id = c.company_id
|
|
||||||
JOIN branches b ON e.branch_id = b.branch_id
|
|
||||||
JOIN employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
|
|
||||||
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
|
||||||
JOIN departments d ON ea.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON ea.job_id = j.job_id
|
|
||||||
WHERE 1=1`;
|
|
||||||
const values: any[] = [];
|
|
||||||
|
|
||||||
if (filters.company_id) { query += ` AND e.company_id = ?`; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { query += ` AND e.branch_id = ?`; values.push(filters.branch_id); }
|
|
||||||
if (filters.department_id) { query += ` AND ea.department_id = ?`; values.push(filters.department_id); }
|
|
||||||
if (filters.is_active !== undefined) { query += ` AND e.is_active = ?`; values.push(filters.is_active); }
|
|
||||||
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findEmployeeById = async (id: number) => {
|
|
||||||
const [empRows] = await pool.query(
|
|
||||||
`SELECT e.employee_id, e.employee_code, e.is_active, p.first_name, p.last_name, p.dob, p.gender,
|
|
||||||
p.personal_email, p.personal_phone, et.work_email, et.date_joining, et.probation_days,
|
|
||||||
et.status AS contract_status, et.salary_structure_id, d.name AS department, d.department_id,
|
|
||||||
j.title AS 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 employment_terms et ON e.employee_id = et.employee_id AND et.status = 'ACTIVE'
|
|
||||||
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
|
||||||
JOIN departments d ON ea.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON ea.job_id = j.job_id
|
|
||||||
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
|
|
||||||
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
|
||||||
WHERE e.employee_id = ?`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return (empRows as any[])[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findAddressesByPartnerId = async (partnerId: number) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT address_type, door_number, landmark, address_line, pincode, district, state
|
|
||||||
FROM addresses WHERE partner_id = ?`,
|
|
||||||
[partnerId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findEmployeeHistory = async (id: number) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT ea.assignment_id, ea.change_reason, ea.effective_from, ea.effective_to, ea.is_current,
|
|
||||||
d.name AS department, j.title AS job_title,
|
|
||||||
mgr_p.first_name AS manager_first_name, mgr_p.last_name AS manager_last_name
|
|
||||||
FROM employee_assignments ea
|
|
||||||
JOIN departments d ON ea.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON ea.job_id = j.job_id
|
|
||||||
LEFT JOIN employees mgr_e ON ea.reporting_to_id = mgr_e.employee_id
|
|
||||||
LEFT JOIN partners mgr_p ON mgr_e.partner_id = mgr_p.partner_id
|
|
||||||
WHERE ea.employee_id = ?
|
|
||||||
ORDER BY ea.effective_from DESC`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPartnerIdByEmployeeId = async (employeeId: number) => {
|
|
||||||
const [rows] = await pool.query(`SELECT partner_id FROM employees WHERE employee_id = ?`, [employeeId]);
|
|
||||||
return (rows as any[])[0]?.partner_id;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertPartner = async (data: any, conn: any) => {
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
[data.firstName, data.lastName, data.dob, data.gender, data.personalEmail, data.personalPhone]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertAddress = async (partnerId: number, data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO addresses (partner_id, address_type, door_number, landmark, address_line, pincode, district, state)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
door_number = VALUES(door_number), landmark = VALUES(landmark), address_line = VALUES(address_line),
|
|
||||||
pincode = VALUES(pincode), district = VALUES(district), state = VALUES(state)`,
|
|
||||||
[partnerId, data.type, data.doorNumber, data.landmark, data.line, data.pincode, data.district, data.state]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertEmployee = async (data: any, conn: any) => {
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[data.empCode, data.partnerId, data.companyId, data.branchId, true]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertEmploymentTerms = async (data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
|
||||||
VALUES (?, ?, ?, ?, 'ACTIVE', ?)`,
|
|
||||||
[data.employeeId, data.work_email, data.dateJoining, data.probationDays, data.salaryStructureId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertEmployeeAssignment = async (data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
|
||||||
VALUES (?, ?, ?, ?, 'HIRE', ?, TRUE)`,
|
|
||||||
[data.employeeId, data.departmentId, data.jobId, data.reportingToId || null, data.dateJoining]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updatePartner = async (partnerId: number, data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE partners 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]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEmploymentTerms = async (employeeId: number, workEmail: string, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE employment_terms SET work_email = ? WHERE employee_id = ? AND status = 'ACTIVE'`,
|
|
||||||
[workEmail, employeeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEmployeeAssignment = async (employeeId: number, data: any, conn: any) => {
|
|
||||||
await conn.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, employeeId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deactivateEmployee = async (id: number) => {
|
|
||||||
const [result] = await pool.execute(`UPDATE employees SET is_active = false WHERE employee_id = ?`, [id]);
|
|
||||||
return result.affectedRows > 0;
|
|
||||||
};
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
// services/ems/repositories/internal.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const findInternalRoster = async (ids?: number[]) => {
|
|
||||||
let query = `
|
|
||||||
SELECT e.employee_id, e.employee_code, e.is_active, CONCAT(p.first_name, ' ', p.last_name) AS full_name,
|
|
||||||
b.branch_id, b.branch_name, c.company_id, c.name AS company_name,
|
|
||||||
d.name AS department_name, d.department_id, j.title AS job_title
|
|
||||||
FROM employees e
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
JOIN companies c ON e.company_id = c.company_id
|
|
||||||
JOIN branches b ON e.branch_id = b.branch_id
|
|
||||||
JOIN employee_assignments ea ON e.employee_id = ea.employee_id AND ea.is_current = TRUE
|
|
||||||
JOIN departments d ON ea.department_id = d.department_id
|
|
||||||
JOIN job_positions j ON ea.job_id = j.job_id
|
|
||||||
WHERE e.is_active = TRUE`;
|
|
||||||
|
|
||||||
const values: any[] = [];
|
|
||||||
if (ids && ids.length > 0) {
|
|
||||||
const placeholders = ids.map(() => "?").join(",");
|
|
||||||
query += ` AND e.employee_id IN (${placeholders})`;
|
|
||||||
values.push(...ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findInternalManager = async (employeeId: number) => {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT ea.reporting_to_id AS manager_id
|
|
||||||
FROM employee_assignments ea
|
|
||||||
WHERE ea.employee_id = ? AND ea.is_current = TRUE`,
|
|
||||||
[employeeId]
|
|
||||||
);
|
|
||||||
return (rows as any[])[0];
|
|
||||||
};
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
// services/ems/repositories/lookup.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// COMPANIES
|
|
||||||
// ==========================================
|
|
||||||
export const findCompanies = async (filters: any) => {
|
|
||||||
let query = "SELECT company_id, company_code, name, is_active FROM companies WHERE 1=1";
|
|
||||||
const values: any[] = [];
|
|
||||||
if (filters.is_active !== undefined) { query += " AND is_active = ?"; values.push(filters.is_active); }
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertCompany = async (data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO companies (company_code, name, parent_id, is_active) VALUES (?, ?, ?, ?)`,
|
|
||||||
[data.company_code, data.name, data.parent_id, data.is_active]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCompanyById = async (id: number, data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE companies SET name = ?, parent_id = ?, is_active = ? WHERE company_id = ?`,
|
|
||||||
[data.name, data.parent_id, data.is_active, id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCompanyById = async (id: number) => {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM companies WHERE company_id = ?`, [id]);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// BRANCHES
|
|
||||||
// ==========================================
|
|
||||||
export const findBranches = async (filters: any) => {
|
|
||||||
let query = `SELECT b.branch_id, b.code, b.branch_name, b.is_active, c.company_code, c.name AS company_name
|
|
||||||
FROM branches b JOIN companies c ON b.company_id = c.company_id WHERE 1=1`;
|
|
||||||
const values: any[] = [];
|
|
||||||
if (filters.company_id) { query += " AND b.company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.is_active !== undefined) { query += " AND b.is_active = ?"; values.push(filters.is_active); }
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertBranch = async (data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO branches (company_id, branch_name, code, is_active) VALUES (?, ?, ?, ?)`,
|
|
||||||
[data.company_id, data.branch_name, data.code, data.is_active]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateBranchById = async (id: number, data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE branches SET company_id = ?, branch_name = ?, code = ?, is_active = ? WHERE branch_id = ?`,
|
|
||||||
[data.company_id, data.branch_name, data.code, data.is_active, id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBranchById = async (id: number) => {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM branches WHERE branch_id = ?`, [id]);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// DEPARTMENTS
|
|
||||||
// ==========================================
|
|
||||||
export const findDepartments = async (filters: any, sort: { column: string; order: string }) => {
|
|
||||||
let query = `SELECT d.department_id, d.department_code, d.name AS department_name, d.is_active,
|
|
||||||
b.branch_name, c.name AS company_name,
|
|
||||||
(SELECT GROUP_CONCAT(CONCAT(p.first_name, ' ', p.last_name) SEPARATOR ', ')
|
|
||||||
FROM department_managers dm
|
|
||||||
JOIN employees e ON dm.employee_id = e.employee_id
|
|
||||||
JOIN partners p ON e.partner_id = p.partner_id
|
|
||||||
WHERE dm.department_id = d.department_id AND dm.is_current = TRUE) AS managers
|
|
||||||
FROM departments d
|
|
||||||
JOIN branches b ON d.branch_id = b.branch_id
|
|
||||||
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
|
|
||||||
const values: any[] = [];
|
|
||||||
|
|
||||||
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
|
||||||
if (filters.is_active !== undefined) { query += " AND d.is_active = ?"; values.push(filters.is_active); }
|
|
||||||
|
|
||||||
query += ` ORDER BY ${sort.column} ${sort.order}`;
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertDepartment = async (data: any, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`INSERT INTO departments (company_id, branch_id, department_code, name, parent_id, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
[data.company_id, data.branch_id, data.department_code, data.name, data.parent_id, data.is_active]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)`,
|
|
||||||
[deptId, empId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDepartmentById = async (id: number, data: any, conn: any) => {
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
`UPDATE departments SET company_id = ?, branch_id = ?, name = ?, parent_id = ?, is_active = ? WHERE department_id = ?`,
|
|
||||||
[data.company_id, data.branch_id, data.name, data.parent_id, data.is_active, id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expireDepartmentManagers = async (deptId: number, conn: any) => {
|
|
||||||
await conn.execute(`UPDATE department_managers SET is_current = FALSE, removed_at = NOW() WHERE department_id = ? AND is_current = TRUE`, [deptId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertDepartmentManager = async (deptId: number, empId: number, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO department_managers (department_id, employee_id, is_current) VALUES (?, ?, TRUE)
|
|
||||||
ON DUPLICATE KEY UPDATE is_current = TRUE, removed_at = NULL`,
|
|
||||||
[deptId, empId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDepartmentById = async (id: number) => {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM departments WHERE department_id = ?`, [id]);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// JOBS
|
|
||||||
// ==========================================
|
|
||||||
export const findJobs = async (filters: any, sort: { column: string; order: string }) => {
|
|
||||||
let query = `SELECT j.job_id, j.job_code, j.title AS job_name, j.is_active,
|
|
||||||
d.name AS department_name, b.branch_name, c.name AS company_name
|
|
||||||
FROM job_positions j
|
|
||||||
JOIN departments d ON j.department_id = d.department_id
|
|
||||||
JOIN branches b ON d.branch_id = b.branch_id
|
|
||||||
JOIN companies c ON d.company_id = c.company_id WHERE 1=1`;
|
|
||||||
const values: any[] = [];
|
|
||||||
|
|
||||||
if (filters.company_id) { query += " AND d.company_id = ?"; values.push(filters.company_id); }
|
|
||||||
if (filters.branch_id) { query += " AND d.branch_id = ?"; values.push(filters.branch_id); }
|
|
||||||
if (filters.department_id) { query += " AND j.department_id = ?"; values.push(filters.department_id); }
|
|
||||||
|
|
||||||
query += ` ORDER BY ${sort.column} ${sort.order}`;
|
|
||||||
const [rows] = await pool.query(query, values);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertJob = async (data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO job_positions (department_id, job_code, title, description, is_active) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[data.department_id, data.job_code, data.title, data.description, data.is_active]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateJobById = async (id: number, data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE job_positions SET department_id = ?, title = ?, description = ?, is_active = ? WHERE job_id = ?`,
|
|
||||||
[data.department_id, data.title, data.description, data.is_active, id]
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteJobById = async (id: number) => {
|
|
||||||
const [result] = await pool.execute(`DELETE FROM job_positions WHERE job_id = ?`, [id]);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
// services/ems/repositories/system.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
export const upsertPartner = async (data: any, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`INSERT INTO partners (first_name, last_name, dob, gender, personal_email, personal_phone)
|
|
||||||
VALUES (?, ?, '1995-01-01', 'Not Specified', ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE partner_id = LAST_INSERT_ID(partner_id)`,
|
|
||||||
[data.firstName, data.lastName, data.email, data.phone]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertDepartment = async (name: string, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`INSERT INTO departments (company_id, branch_id, department_code, name)
|
|
||||||
VALUES (1, 1, CONCAT('DEPT-', LEFT(?, 3)), ?)
|
|
||||||
ON DUPLICATE KEY UPDATE department_id = LAST_INSERT_ID(department_id)`,
|
|
||||||
[name, name]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertJob = async (deptId: number, title: string, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`INSERT INTO job_positions (department_id, job_code, title)
|
|
||||||
VALUES (?, CONCAT('JOB-', LEFT(?, 3)), ?)
|
|
||||||
ON DUPLICATE KEY UPDATE job_id = LAST_INSERT_ID(job_id)`,
|
|
||||||
[deptId, title, title]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertEmployee = async (empCode: string, partnerId: number, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`INSERT INTO employees (employee_code, partner_id, company_id, branch_id, is_active)
|
|
||||||
VALUES (?, ?, 1, 1, TRUE)
|
|
||||||
ON DUPLICATE KEY UPDATE employee_id = LAST_INSERT_ID(employee_id)`,
|
|
||||||
[empCode, partnerId]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertEmploymentTerms = async (employeeId: number, email: string, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO employment_terms (employee_id, work_email, date_joining, probation_days, status, salary_structure_id)
|
|
||||||
VALUES (?, ?, '2026-01-01', 90, 'ACTIVE', 100)
|
|
||||||
ON DUPLICATE KEY UPDATE work_email = VALUES(work_email)`,
|
|
||||||
[employeeId, email]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertAssignment = async (employeeId: number, deptId: number, jobId: number, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO employee_assignments (employee_id, department_id, job_id, reporting_to_id, change_reason, effective_from, is_current)
|
|
||||||
VALUES (?, ?, ?, NULL, 'HIRE', '2026-01-01', TRUE)
|
|
||||||
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id), job_id = VALUES(job_id)`,
|
|
||||||
[employeeId, deptId, jobId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateAssignmentManager = async (empCode: string, managerCode: string, conn: any) => {
|
|
||||||
const [result]: any = await conn.execute(
|
|
||||||
`UPDATE employee_assignments ea
|
|
||||||
JOIN employees e ON ea.employee_id = e.employee_id
|
|
||||||
JOIN employees m ON m.employee_code = ?
|
|
||||||
SET ea.reporting_to_id = m.employee_id
|
|
||||||
WHERE e.employee_code = ? AND ea.is_current = TRUE`,
|
|
||||||
[managerCode, empCode]
|
|
||||||
);
|
|
||||||
return result.affectedRows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertDepartmentManager = async (deptName: string, managerCode: string, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT IGNORE INTO department_managers (department_id, employee_id, is_current)
|
|
||||||
SELECT d.department_id, m.employee_id, TRUE
|
|
||||||
FROM departments d
|
|
||||||
JOIN employees m ON m.employee_code = ?
|
|
||||||
WHERE d.name = ?`,
|
|
||||||
[managerCode, deptName]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDepartmentParent = async (deptName: string, parentName: string, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`UPDATE departments d
|
|
||||||
JOIN departments p ON p.name = ?
|
|
||||||
SET d.parent_id = p.department_id
|
|
||||||
WHERE d.name = ?`,
|
|
||||||
[parentName, deptName]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
// services/ems/routes.ts
|
|
||||||
import { Router } from "@oak/oak";
|
|
||||||
import { requireAuth, requireRole, AppRole } from "core/auth.ts";
|
|
||||||
|
|
||||||
import * as EmpCtrl from "./controllers/employee.controller.ts";
|
|
||||||
import * as LookupCtrl from "./controllers/lookup.controller.ts";
|
|
||||||
import * as ContractCtrl from "./controllers/contract.controller.ts";
|
|
||||||
import * as DashboardCtrl from "./controllers/dashboard.controller.ts";
|
|
||||||
import * as InternalCtrl from "./controllers/internal.controller.ts";
|
|
||||||
import * as SystemCtrl from "./controllers/system.controller.ts";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
// INTERNAL API
|
|
||||||
router.get("/internal/employees/roster", InternalCtrl.getInternalEmployeeRoster);
|
|
||||||
router.get("/internal/employees/:id/manager", InternalCtrl.getInternalManager);
|
|
||||||
|
|
||||||
// EMPLOYEE CORE
|
|
||||||
router.get("/employees", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER, AppRole.MANAGER]), EmpCtrl.getEmployees);
|
|
||||||
router.get("/employees/:id", requireAuth, EmpCtrl.getEmployeeById);
|
|
||||||
router.get("/employees/:id/history", requireAuth, EmpCtrl.getEmployeeHistory);
|
|
||||||
router.post("/employees", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.createEmployee);
|
|
||||||
router.put("/employees/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.updateEmployee);
|
|
||||||
router.delete("/employees/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), EmpCtrl.deleteEmployee);
|
|
||||||
|
|
||||||
// CONTRACT & ASSIGNMENT DOMAIN
|
|
||||||
router.get("/employees/:id/contracts", requireAuth, ContractCtrl.getEmployeeContracts);
|
|
||||||
router.post("/contracts", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), ContractCtrl.createContract);
|
|
||||||
|
|
||||||
// ORGANIZATIONAL LOOKUP DOMAIN
|
|
||||||
router.get("/companies", requireAuth, LookupCtrl.getCompanies);
|
|
||||||
router.post("/companies", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createCompany);
|
|
||||||
router.put("/companies/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateCompany);
|
|
||||||
router.delete("/companies/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteCompany);
|
|
||||||
|
|
||||||
router.get("/branches", requireAuth, LookupCtrl.getBranches);
|
|
||||||
router.post("/branches", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createBranch);
|
|
||||||
router.put("/branches/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateBranch);
|
|
||||||
router.delete("/branches/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteBranch);
|
|
||||||
|
|
||||||
router.get("/departments", requireAuth, LookupCtrl.getDepartments);
|
|
||||||
router.post("/departments", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createDepartment);
|
|
||||||
router.put("/departments/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateDepartment);
|
|
||||||
router.delete("/departments/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteDepartment);
|
|
||||||
|
|
||||||
router.get("/jobs", requireAuth, LookupCtrl.getJobs);
|
|
||||||
router.post("/jobs", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.createJob);
|
|
||||||
router.put("/jobs/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.updateJob);
|
|
||||||
router.delete("/jobs/:id", requireAuth, requireRole([AppRole.DIRECTOR, AppRole.HR_MANAGER]), LookupCtrl.deleteJob);
|
|
||||||
|
|
||||||
// DASHBOARD METRICS
|
|
||||||
router.get("/dashboard/metrics", requireAuth, DashboardCtrl.getDashboardMetrics);
|
|
||||||
|
|
||||||
// SYSTEM & MIGRATION DOMAIN
|
|
||||||
router.post("/system/seed-employees", requireAuth, requireRole([AppRole.DIRECTOR]), SystemCtrl.bulkSeedEmployees);
|
|
||||||
router.post("/system/map-hierarchy", requireAuth, requireRole([AppRole.DIRECTOR]), SystemCtrl.bulkMapHierarchy);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
// services/ems/services/contract.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import * as ContractRepo from "../repositories/contract.repository.ts";
|
|
||||||
|
|
||||||
export const getEmployeeContracts = async (employeeId: number) => {
|
|
||||||
return await ContractRepo.findContractsByEmployeeId(employeeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createContract = async (data: any) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
await ContractRepo.expireCurrentAssignment(data.employeeId, connection);
|
|
||||||
const assignmentId = await ContractRepo.insertNewAssignment(data, connection);
|
|
||||||
await connection.commit();
|
|
||||||
return { assignment_id: assignmentId };
|
|
||||||
} catch (error: any) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
// services/ems/services/dashboard.service.ts
|
|
||||||
import * as DashboardRepo from "../repositories/dashboard.repository.ts";
|
|
||||||
|
|
||||||
export const getDashboardMetrics = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
|
||||||
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
|
|
||||||
if (params.get('department_id')) filters.department_id = params.get('department_id');
|
|
||||||
|
|
||||||
const [total_companies, total_branches, total_departments, total_jobs, total_employees] = await Promise.all([
|
|
||||||
DashboardRepo.countCompanies(),
|
|
||||||
DashboardRepo.countBranches(filters),
|
|
||||||
DashboardRepo.countDepartments(filters),
|
|
||||||
DashboardRepo.countJobs(filters),
|
|
||||||
DashboardRepo.countEmployees(filters)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { total_companies, total_branches, total_departments, total_jobs, total_employees };
|
|
||||||
};
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
// services/ems/services/employee.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { generateNextCode } from "../sequence.ts";
|
|
||||||
import * as EmpRepo from "../repositories/employee.repository.ts";
|
|
||||||
|
|
||||||
export const getEmployees = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
|
||||||
if (params.get('branch_id')) filters.branch_id = params.get('branch_id');
|
|
||||||
if (params.get('department_id')) filters.department_id = params.get('department_id');
|
|
||||||
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
|
||||||
|
|
||||||
return await EmpRepo.findEmployees(filters);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeById = async (id: number) => {
|
|
||||||
const employee = await EmpRepo.findEmployeeById(id);
|
|
||||||
if (!employee) throw new Error("Employee not found");
|
|
||||||
|
|
||||||
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
|
|
||||||
const addresses = await EmpRepo.findAddressesByPartnerId(partnerId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee_id: employee.employee_id,
|
|
||||||
employee_code: employee.employee_code,
|
|
||||||
is_active: employee.is_active,
|
|
||||||
first_name: employee.first_name,
|
|
||||||
last_name: employee.last_name,
|
|
||||||
dob: employee.dob,
|
|
||||||
gender: employee.gender,
|
|
||||||
personal_email: employee.personal_email,
|
|
||||||
personal_phone: employee.personal_phone,
|
|
||||||
contract: {
|
|
||||||
work_email: employee.work_email,
|
|
||||||
date_joining: employee.date_joining,
|
|
||||||
probation_days: employee.probation_days,
|
|
||||||
status: employee.contract_status,
|
|
||||||
salary_structure_id: employee.salary_structure_id,
|
|
||||||
department: employee.department,
|
|
||||||
department_id: employee.department_id,
|
|
||||||
designation: employee.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: addresses,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEmployeeHistory = async (id: number) => {
|
|
||||||
return await EmpRepo.findEmployeeHistory(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createEmployee = async (data: any) => {
|
|
||||||
const empCode = data.employeeCode || await generateNextCode("EMPLOYEE_MAIN", "CLRI", 3);
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const partnerId = await EmpRepo.insertPartner(data, connection);
|
|
||||||
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
|
|
||||||
|
|
||||||
const employeeId = await EmpRepo.insertEmployee({ ...data, empCode, partnerId }, connection);
|
|
||||||
await EmpRepo.insertEmploymentTerms({ ...data, employeeId }, connection);
|
|
||||||
await EmpRepo.insertEmployeeAssignment({ ...data, employeeId }, connection);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
return { employee_id: employeeId, employee_code: empCode };
|
|
||||||
} catch (error: any) {
|
|
||||||
await connection.rollback();
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error("Employee Code, Email, or Phone already exists.");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEmployee = async (id: number, data: any) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const partnerId = await EmpRepo.findPartnerIdByEmployeeId(id);
|
|
||||||
if (!partnerId) throw new Error("Employee not found");
|
|
||||||
|
|
||||||
await EmpRepo.updatePartner(partnerId, data, connection);
|
|
||||||
if (data.address) await EmpRepo.upsertAddress(partnerId, data.address, connection);
|
|
||||||
if (data.work_email) await EmpRepo.updateEmploymentTerms(id, data.work_email, connection);
|
|
||||||
if (data.departmentId || data.jobId || data.reportingToId) await EmpRepo.updateEmployeeAssignment(id, data, connection);
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
} catch (error: any) {
|
|
||||||
await connection.rollback();
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error("Email or Phone already belongs to another employee.");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEmployee = async (id: number) => {
|
|
||||||
const success = await EmpRepo.deactivateEmployee(id);
|
|
||||||
if (!success) throw new Error("Employee not found");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
// services/ems/services/internal.service.ts
|
|
||||||
import * as InternalRepo from "../repositories/internal.repository.ts";
|
|
||||||
|
|
||||||
export const getRoster = async (idsParam?: string) => {
|
|
||||||
const ids = idsParam ? idsParam.split(",").map(Number) : undefined;
|
|
||||||
return await InternalRepo.findInternalRoster(ids);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getManager = async (employeeId: number) => {
|
|
||||||
const data = await InternalRepo.findInternalManager(employeeId);
|
|
||||||
if (!data) throw new Error("Employee not found");
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
// services/ems/services/lookup.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { generateNextCode } from "../sequence.ts";
|
|
||||||
import * as LookupRepo from "../repositories/lookup.repository.ts";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// COMPANIES
|
|
||||||
// ==========================================
|
|
||||||
export const getCompanies = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
|
||||||
return await LookupRepo.findCompanies(filters);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCompany = async (data: any) => {
|
|
||||||
const companyCode = data.companyCode || await generateNextCode("COMPANY_MAIN", "CO-", 3);
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.insertCompany({
|
|
||||||
company_code: companyCode,
|
|
||||||
name: data.name,
|
|
||||||
parent_id: data.parentId || null,
|
|
||||||
is_active: data.isActive ?? true
|
|
||||||
});
|
|
||||||
return { company_id: result.insertId, company_code: companyCode };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Company code '${companyCode}' already exists.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateCompany = async (id: number, data: any) => {
|
|
||||||
const result = await LookupRepo.updateCompanyById(id, {
|
|
||||||
name: data.name,
|
|
||||||
parent_id: data.parentId || null,
|
|
||||||
is_active: data.isActive
|
|
||||||
});
|
|
||||||
if (result.affectedRows === 0) throw new Error("Company not found");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteCompany = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.deleteCompanyById(id);
|
|
||||||
if (result.affectedRows === 0) throw new Error("Company not found");
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete company. It is currently assigned to one or more employees.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// BRANCHES
|
|
||||||
// ==========================================
|
|
||||||
export const getBranches = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('company_id')) filters.company_id = params.get('company_id');
|
|
||||||
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
|
||||||
return await LookupRepo.findBranches(filters);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createBranch = async (data: any) => {
|
|
||||||
let branchCode = data.code;
|
|
||||||
if (!branchCode || branchCode.trim() === "") {
|
|
||||||
const shortName = data.branchName ? data.branchName.substring(0, 3).toUpperCase() : 'BRN';
|
|
||||||
branchCode = await generateNextCode(`BRANCH_${shortName}`, `${shortName}-`, 3);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.insertBranch({
|
|
||||||
company_id: data.companyId,
|
|
||||||
branch_name: data.branchName,
|
|
||||||
code: branchCode,
|
|
||||||
is_active: data.isActive ?? true
|
|
||||||
});
|
|
||||||
return { branch_id: result.insertId, code: branchCode };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Branch code '${branchCode}' already exists.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateBranch = async (id: number, data: any) => {
|
|
||||||
const result = await LookupRepo.updateBranchById(id, {
|
|
||||||
company_id: data.companyId,
|
|
||||||
branch_name: data.branchName,
|
|
||||||
code: data.code,
|
|
||||||
is_active: data.isActive
|
|
||||||
});
|
|
||||||
if (result.affectedRows === 0) throw new Error("Branch not found");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteBranch = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.deleteBranchById(id);
|
|
||||||
if (result.affectedRows === 0) throw new Error("Branch not found");
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete branch. It is currently assigned to one or more employees.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// DEPARTMENTS
|
|
||||||
// ==========================================
|
|
||||||
const mapDeptSortColumn = (sortBy: string): string => {
|
|
||||||
const validColumns: Record<string, string> = {
|
|
||||||
'department_name': 'd.name', 'department_code': 'd.department_code', 'company_name': 'c.name', 'branch_name': 'b.branch_name'
|
|
||||||
};
|
|
||||||
return validColumns[sortBy] || 'd.name';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDepartments = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
|
|
||||||
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
|
|
||||||
if (params.get('is_active')) filters.is_active = params.get('is_active') === 'true';
|
|
||||||
|
|
||||||
const sort = {
|
|
||||||
column: mapDeptSortColumn(params.get('sort_by') || 'department_name'),
|
|
||||||
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
|
|
||||||
};
|
|
||||||
|
|
||||||
return await LookupRepo.findDepartments(filters, sort);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createDepartment = async (data: any) => {
|
|
||||||
if (!data.companyId || !data.branchId || !data.name) throw new Error("companyId, branchId, and name are required.");
|
|
||||||
|
|
||||||
const deptCode = data.departmentCode || await generateNextCode("DEPARTMENT_MAIN", "DEPT-", 3);
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const departmentId = await LookupRepo.insertDepartment({
|
|
||||||
company_id: data.companyId,
|
|
||||||
branch_id: data.branchId,
|
|
||||||
department_code: deptCode,
|
|
||||||
name: data.name,
|
|
||||||
parent_id: data.parentId ?? null,
|
|
||||||
is_active: data.isActive ?? true
|
|
||||||
}, connection);
|
|
||||||
|
|
||||||
if (data.managerId) {
|
|
||||||
await LookupRepo.insertDepartmentManager(departmentId, data.managerId, connection);
|
|
||||||
}
|
|
||||||
await connection.commit();
|
|
||||||
return { department_id: departmentId, department_code: deptCode };
|
|
||||||
} catch (error: any) {
|
|
||||||
await connection.rollback();
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Department code '${deptCode}' already exists.`);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateDepartment = async (id: number, data: any) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const result = await LookupRepo.updateDepartmentById(id, {
|
|
||||||
company_id: data.companyId ?? null,
|
|
||||||
branch_id: data.branchId ?? null,
|
|
||||||
name: data.name ?? null,
|
|
||||||
parent_id: data.parentId ?? null,
|
|
||||||
is_active: data.isActive
|
|
||||||
}, connection);
|
|
||||||
|
|
||||||
if (result.affectedRows === 0) throw new Error("Department not found");
|
|
||||||
|
|
||||||
if (data.managerId) {
|
|
||||||
await LookupRepo.expireDepartmentManagers(id, connection);
|
|
||||||
await LookupRepo.upsertDepartmentManager(id, data.managerId, connection);
|
|
||||||
}
|
|
||||||
await connection.commit();
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDepartment = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.deleteDepartmentById(id);
|
|
||||||
if (result.affectedRows === 0) throw new Error("Department not found");
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete department. There are active assignments tied to it.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// JOBS
|
|
||||||
// ==========================================
|
|
||||||
const mapJobSortColumn = (sortBy: string): string => {
|
|
||||||
const validColumns: Record<string, string> = {
|
|
||||||
'job_name': 'j.title', 'job_code': 'j.job_code', 'department_name': 'd.name'
|
|
||||||
};
|
|
||||||
return validColumns[sortBy] || 'j.title';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getJobs = async (params: any) => {
|
|
||||||
const filters: any = {};
|
|
||||||
if (params.get('company_id')) filters.company_id = Number(params.get('company_id'));
|
|
||||||
if (params.get('branch_id')) filters.branch_id = Number(params.get('branch_id'));
|
|
||||||
if (params.get('department_id')) filters.department_id = Number(params.get('department_id'));
|
|
||||||
|
|
||||||
const sort = {
|
|
||||||
column: mapJobSortColumn(params.get('sort_by') || 'job_name'),
|
|
||||||
order: params.get('sort_order') === 'desc' ? 'DESC' : 'ASC'
|
|
||||||
};
|
|
||||||
|
|
||||||
return await LookupRepo.findJobs(filters, sort);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createJob = async (data: any) => {
|
|
||||||
if (!data.departmentId || !data.title) throw new Error("departmentId and title are required.");
|
|
||||||
|
|
||||||
const jobCode = data.jobCode || await generateNextCode("JOB_MAIN", "JOB-", 3);
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.insertJob({
|
|
||||||
department_id: data.departmentId,
|
|
||||||
job_code: jobCode,
|
|
||||||
title: data.title,
|
|
||||||
description: data.description ?? null,
|
|
||||||
is_active: data.isActive ?? true
|
|
||||||
});
|
|
||||||
return { job_id: result.insertId, job_code: jobCode };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.code === 'ER_DUP_ENTRY') throw new Error(`Job code '${jobCode}' already exists.`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateJob = async (id: number, data: any) => {
|
|
||||||
const result = await LookupRepo.updateJobById(id, {
|
|
||||||
department_id: data.departmentId ?? null,
|
|
||||||
title: data.title ?? null,
|
|
||||||
description: data.description ?? null,
|
|
||||||
is_active: data.isActive
|
|
||||||
});
|
|
||||||
if (result.affectedRows === 0) throw new Error("Job not found");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteJob = async (id: number) => {
|
|
||||||
try {
|
|
||||||
const result = await LookupRepo.deleteJobById(id);
|
|
||||||
if (result.affectedRows === 0) throw new Error("Job not found");
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("foreign key constraint fails")) throw new Error("Cannot delete job position. It is currently linked to active employee assignments.");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
// services/ems/services/system.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import * as SystemRepo from "../repositories/system.repository.ts";
|
|
||||||
|
|
||||||
export const bulkSeedEmployees = async (body: any[]) => {
|
|
||||||
if (!Array.isArray(body) || body.length === 0) throw new Error("Provide an array of employee logs.");
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
let insertedCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
for (const item of body) {
|
|
||||||
const { emp_code, name, designation, department, email } = item;
|
|
||||||
const nameParts = name.trim().split(" ");
|
|
||||||
const firstName = nameParts[0] || "Employee";
|
|
||||||
const lastName = nameParts.slice(1).join(" ") || "LNU";
|
|
||||||
|
|
||||||
const partnerId = await SystemRepo.upsertPartner({ firstName, lastName, email: `personal.${email}`, phone: `MOCK_${emp_code}` }, connection);
|
|
||||||
const departmentId = await SystemRepo.upsertDepartment(department || "General", connection);
|
|
||||||
const jobId = await SystemRepo.upsertJob(departmentId, designation || "Trainee", connection);
|
|
||||||
const employeeId = await SystemRepo.upsertEmployee(emp_code, partnerId, connection);
|
|
||||||
|
|
||||||
await SystemRepo.upsertEmploymentTerms(employeeId, email, connection);
|
|
||||||
await SystemRepo.upsertAssignment(employeeId, departmentId, jobId, connection);
|
|
||||||
insertedCount++;
|
|
||||||
}
|
|
||||||
await connection.commit();
|
|
||||||
return { count: insertedCount };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkMapHierarchy = async (body: any) => {
|
|
||||||
const { employeeHierarchy = [], departmentHierarchy = [] } = body;
|
|
||||||
if (employeeHierarchy.length === 0 && departmentHierarchy.length === 0) throw new Error("Provide hierarchy mapping arrays.");
|
|
||||||
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
let assignmentsUpdated = 0;
|
|
||||||
let departmentsUpdated = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
for (const mapping of employeeHierarchy) {
|
|
||||||
if (mapping.emp_code && mapping.manager_code) {
|
|
||||||
const affected = await SystemRepo.updateAssignmentManager(mapping.emp_code, mapping.manager_code, connection);
|
|
||||||
if (affected > 0) assignmentsUpdated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dept of departmentHierarchy) {
|
|
||||||
if (!dept.department_name) continue;
|
|
||||||
if (dept.manager_code) await SystemRepo.upsertDepartmentManager(dept.department_name, dept.manager_code, connection);
|
|
||||||
if (dept.parent_department_name) await SystemRepo.updateDepartmentParent(dept.department_name, dept.parent_department_name, connection);
|
|
||||||
departmentsUpdated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
return { assignmentsUpdated, departmentsProcessed: departmentsUpdated };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@service/gateway",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"exports": "./main.ts"
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
// services/gateway/main.ts
|
|
||||||
import { Application, Router } from "@oak/oak";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
// Read config from environment variables
|
|
||||||
const EMS_URL = Deno.env.get("EMS_URL") || "http://localhost:8001";
|
|
||||||
const AMS_URL = Deno.env.get("AMS_URL") || "http://localhost:8002";
|
|
||||||
const LMS_URL = Deno.env.get("LMS_URL") || "http://localhost:8003";
|
|
||||||
const INTERNAL_TOKEN = Deno.env.get("INTERNAL_SERVICE_TOKEN") || "";
|
|
||||||
const PORT = Number(Deno.env.get("GATEWAY_PORT")) || 8000;
|
|
||||||
|
|
||||||
// MOCK SSO MIDDLEWARE
|
|
||||||
router.use(async (ctx, next) => {
|
|
||||||
const isAuthenticated = true; // Simulating a valid JWT
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
ctx.response.status = 401;
|
|
||||||
ctx.response.body = { success: false, message: "Unauthorized: Invalid or missing token" };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIX: Store context in state instead of modifying immutable request headers
|
|
||||||
ctx.state.user = {
|
|
||||||
id: "135",
|
|
||||||
role: "HR_MANAGER", // Changed from ADMIN
|
|
||||||
managed_branches: "1,2"
|
|
||||||
};
|
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// DYNAMIC REVERSE PROXY
|
|
||||||
const proxyTo = async (ctx: any, targetBaseUrl: string, pathPrefix: string) => {
|
|
||||||
const url = new URL(ctx.request.url);
|
|
||||||
const targetPath = url.pathname.replace(pathPrefix, "") || "/";
|
|
||||||
const targetUrl = `${targetBaseUrl}${targetPath}${url.search}`;
|
|
||||||
|
|
||||||
const headers = new Headers(ctx.request.headers);
|
|
||||||
headers.delete("host");
|
|
||||||
|
|
||||||
headers.set("X-Internal-Token", INTERNAL_TOKEN);
|
|
||||||
|
|
||||||
if (ctx.state.user) {
|
|
||||||
headers.set("X-User-Id", ctx.state.user.id);
|
|
||||||
headers.set("X-User-Role", ctx.state.user.role);
|
|
||||||
headers.set("X-Managed-Branches", ctx.state.user.managed_branches);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqInit: RequestInit = {
|
|
||||||
method: ctx.request.method,
|
|
||||||
headers: headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIX: Correct Oak v17 syntax for reading body as text
|
|
||||||
if (ctx.request.method !== "GET" && ctx.request.method !== "HEAD" && ctx.request.hasBody) {
|
|
||||||
try {
|
|
||||||
const bodyText = await ctx.request.body.text();
|
|
||||||
reqInit.body = bodyText;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error reading body in Gateway:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(targetUrl, reqInit);
|
|
||||||
ctx.response.status = response.status;
|
|
||||||
ctx.response.headers.set("Content-Type", response.headers.get("Content-Type") || "application/json");
|
|
||||||
ctx.response.body = response.body;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Proxy error to ${targetUrl}:`, error);
|
|
||||||
ctx.response.status = 502;
|
|
||||||
ctx.response.body = { success: false, message: "Bad Gateway: Microservice unavailable" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
router.all("/api/ems/(.*)", async (ctx) => proxyTo(ctx, EMS_URL, "/api/ems"));
|
|
||||||
router.all("/api/ams/(.*)", async (ctx) => proxyTo(ctx, AMS_URL, "/api/ams"));
|
|
||||||
router.all("/api/lms/(.*)", async (ctx) => proxyTo(ctx, LMS_URL, "/api/lms"));
|
|
||||||
|
|
||||||
const app = new Application();
|
|
||||||
|
|
||||||
// CORS
|
|
||||||
app.use(async (ctx, next) => {
|
|
||||||
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
|
||||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
||||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
||||||
if (ctx.request.method === "OPTIONS") {
|
|
||||||
ctx.response.status = 204;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(router.routes());
|
|
||||||
app.use(router.allowedMethods());
|
|
||||||
|
|
||||||
console.log(`API Gateway running on http://localhost:${PORT}`);
|
|
||||||
await app.listen({ port: PORT });
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
// services/lms/controllers/admin.controller.ts
|
|
||||||
import * as AdminService from "../services/admin.service.ts";
|
|
||||||
|
|
||||||
const getJsonBody = async (ctx: any) => {
|
|
||||||
try { return await ctx.request.body.json(); }
|
|
||||||
catch { throw new Error("Invalid JSON body. Check Postman variables and raw JSON format."); }
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createLeaveType = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const id = await AdminService.createLeaveType(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Leave type created", leave_type_id: id };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLeaveTypes = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const companyId = Number(ctx.request.url.searchParams.get("company_id"));
|
|
||||||
const data = await AdminService.getLeaveTypes(companyId);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createPolicyRule = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
const id = await AdminService.createPolicyRule(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: "Policy rule created", rule_id: id };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCompanyHoliday = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await AdminService.createCompanyHoliday(body);
|
|
||||||
ctx.response.status = 201; ctx.response.body = { success: true, message: `${body.length} holidays inserted successfully.` };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateWorkSettings = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const body = await getJsonBody(ctx);
|
|
||||||
await AdminService.updateWorkSettings(body);
|
|
||||||
ctx.response.body = { success: true, message: "Work settings updated successfully." };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// services/lms/controllers/employee.controller.ts
|
|
||||||
import * as EmployeeService from "../services/employee.service.ts";
|
|
||||||
|
|
||||||
export const getLeaveBalances = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getLeaveBalances(ctx.state.user, ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, balances: data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLeaveHistory = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getLeaveHistory(ctx.state.user, ctx.request.url.searchParams);
|
|
||||||
ctx.response.body = { success: true, count: data.length, history: data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getValidOptionalHolidays = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await EmployeeService.getValidOptionalHolidays(ctx.state.user);
|
|
||||||
ctx.response.body = { success: true, count: data.length, holidays: data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
// services/lms/controllers/leave.controller.ts
|
|
||||||
import * as LeaveService from "../services/leave.service.ts";
|
|
||||||
|
|
||||||
export const applyForLeave = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
let body;
|
|
||||||
try { body = await ctx.request.body.json(); }
|
|
||||||
catch { throw new Error("Invalid JSON body. Check Postman variables and raw JSON format."); }
|
|
||||||
|
|
||||||
const result = await LeaveService.applyForLeave(body);
|
|
||||||
ctx.response.status = 201;
|
|
||||||
ctx.response.body = { success: true, message: "Leave application submitted successfully.", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
// services/lms/controllers/manager.controller.ts
|
|
||||||
import * as ManagerService from "../services/manager.service.ts";
|
|
||||||
|
|
||||||
export const getPendingManagerLeaves = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const data = await ManagerService.getPendingManagerLeaves(ctx.state.user);
|
|
||||||
ctx.response.body = { success: true, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 500; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const approveLeaveManager = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
const result = await ManagerService.approveLeaveManager(ctx.state.user, Number(ctx.params.applicationId));
|
|
||||||
ctx.response.body = { success: true, message: "Leave approved successfully.", ...result };
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message.includes("Access Denied")) ctx.response.status = 403;
|
|
||||||
else if (error.message.includes("not found")) ctx.response.status = 404;
|
|
||||||
else ctx.response.status = 400;
|
|
||||||
ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rejectLeaveManager = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
// We still need to parse the body to prevent Oak from crashing, even if we only use the applicationId from the URL
|
|
||||||
try { await ctx.request.body.json(); }
|
|
||||||
catch { throw new Error("Invalid JSON body."); }
|
|
||||||
|
|
||||||
await ManagerService.rejectLeaveManager(ctx.state.user, Number(ctx.params.applicationId));
|
|
||||||
ctx.response.body = { success: true, message: "Leave rejected successfully." };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 404; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
// services/lms/controllers/reports.controller.ts
|
|
||||||
import * as ReportsService from "../services/reports.service.ts";
|
|
||||||
|
|
||||||
export const getLedgerReport = async (ctx: any) => {
|
|
||||||
try {
|
|
||||||
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) throw new Error("Missing required query params: month, year, company_id");
|
|
||||||
|
|
||||||
const data = await ReportsService.getLedgerReport(month, year, companyId);
|
|
||||||
ctx.response.body = { success: true, report_month: month, report_year: year, count: data.length, data };
|
|
||||||
} catch (error: any) {
|
|
||||||
ctx.response.status = 400; ctx.response.body = { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
// services/lms/db.ts
|
|
||||||
import mysql from "mysql2/promise";
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: Deno.env.get("DB_HOST") || "127.0.0.1",
|
|
||||||
port: Number(Deno.env.get("LMS_DB_PORT")) || 3308,
|
|
||||||
user: Deno.env.get("DB_USER") || "admin",
|
|
||||||
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
|
||||||
database: "hrms_lms",
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default pool;
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
// services/lms/repositories/lms.repository.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CONFIG (Types, Rules, Holidays, Settings)
|
|
||||||
// ==========================================
|
|
||||||
export const insertLeaveType = async (data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO leave_types (company_id, name, requires_allocation, carry_over_allowed, max_carry_over_days) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
[data.company_id, data.name, data.requires_allocation ?? true, data.carry_over_allowed ?? false, data.max_carry_over_days ?? 0.0]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findLeaveTypes = async (companyId: number) => {
|
|
||||||
const [rows] = await pool.execute(`SELECT * FROM leave_types WHERE company_id = ?`, [companyId]);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertPolicyRule = async (data: any) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`INSERT INTO leave_policy_rules (leave_type_id, company_id, branch_id, calendar_year, yearly_allowance, max_days_per_month, max_consecutive_days, apply_sandwich_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[data.leave_type_id, data.company_id, data.branch_id || null, data.calendar_year, data.yearly_allowance, data.max_days_per_month || null, data.max_consecutive_days || null, data.apply_sandwich_policy ?? false]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bulkInsertHolidays = async (values: any[]) => {
|
|
||||||
await pool.query(`INSERT INTO company_holidays (company_id, branch_id, calendar_year, holiday_date, holiday_type, holiday_name) VALUES ?`, [values]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertWorkSettings = async (data: any) => {
|
|
||||||
await pool.execute(
|
|
||||||
`INSERT INTO branch_work_settings (company_id, branch_id, weekly_off_days, effective_from) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE weekly_off_days = VALUES(weekly_off_days), effective_from = VALUES(effective_from)`,
|
|
||||||
[data.company_id, data.branch_id || null, data.offDaysJson, data.effective_from]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EMPLOYEE DASHBOARD
|
|
||||||
// ==========================================
|
|
||||||
export const findLeaveBalances = async (employeeId: number, year: number) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT lt.leave_type_id, lt.name AS leave_type_name, la.granted_days, la.used_days, (la.granted_days - la.used_days) AS available_balance
|
|
||||||
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.employee_id = ? AND la.calendar_year = ? AND la.status = 'ACTIVE'`,
|
|
||||||
[employeeId, year]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findLeaveHistory = async (employeeId: number, year: number) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT la.application_id, lt.name AS leave_type_name, DATE_FORMAT(la.date_from, '%Y-%m-%d') as date_from, DATE_FORMAT(la.date_to, '%Y-%m-%d') as date_to, la.number_of_days, la.status, la.reason
|
|
||||||
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.employee_id = ? AND YEAR(la.date_from) = ? ORDER BY la.date_from DESC`,
|
|
||||||
[employeeId, year]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findOptionalHolidays = async (companyId: number, branchId: number | null, year: number, today: string) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT holiday_id, holiday_name, DATE_FORMAT(holiday_date, '%Y-%m-%d') as holiday_date
|
|
||||||
FROM company_holidays
|
|
||||||
WHERE company_id = ? AND calendar_year = ? AND holiday_type = 'OPTIONAL' AND (branch_id = ? OR branch_id IS NULL) AND holiday_date >= ?
|
|
||||||
ORDER BY holiday_date ASC`,
|
|
||||||
[companyId, year, branchId, today]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// LEAVE APPLICATION
|
|
||||||
// ==========================================
|
|
||||||
export const findWorkSettings = async (companyId: number, branchId: number) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT weekly_off_days FROM branch_work_settings WHERE company_id = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
|
||||||
[companyId, branchId]
|
|
||||||
);
|
|
||||||
return rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findHolidays = async (companyId: number, year: number, branchId: number) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT holiday_date FROM company_holidays WHERE company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL)`,
|
|
||||||
[companyId, year, branchId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPolicyRule = async (leaveTypeId: number, companyId: number, year: number, branchId: number) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT max_days_per_month, apply_sandwich_policy FROM leave_policy_rules WHERE leave_type_id = ? AND company_id = ? AND calendar_year = ? AND (branch_id = ? OR branch_id IS NULL) ORDER BY branch_id DESC LIMIT 1`,
|
|
||||||
[leaveTypeId, companyId, year, branchId]
|
|
||||||
);
|
|
||||||
return rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findRawBalance = async (employeeId: number, leaveTypeId: number, year: number) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT (granted_days - used_days) AS raw_balance FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE'`,
|
|
||||||
[employeeId, leaveTypeId, year]
|
|
||||||
);
|
|
||||||
return rows[0]?.raw_balance || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findPendingDays = async (employeeId: number, leaveTypeId: number, year: number) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT SUM(number_of_days) as pending_days FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND YEAR(date_from) = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
|
||||||
[employeeId, leaveTypeId, year]
|
|
||||||
);
|
|
||||||
return Number(rows[0]?.pending_days || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findMonthlyUsage = async (employeeId: number, leaveTypeId: number, month: number, year: number) => {
|
|
||||||
const [rows]: any = await pool.execute(
|
|
||||||
`SELECT SUM(number_of_days) as used_this_month FROM leave_applications WHERE employee_id = ? AND leave_type_id = ? AND MONTH(date_from) = ? AND YEAR(date_from) = ? AND status IN ('APPROVED', 'APPROVED_LOP', 'PENDING', 'PENDING_LOP')`,
|
|
||||||
[employeeId, leaveTypeId, month, year]
|
|
||||||
);
|
|
||||||
return Number(rows[0]?.used_this_month || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertLeaveApplication = async (data: any, conn: any) => {
|
|
||||||
const [result] = await conn.execute(
|
|
||||||
`INSERT INTO leave_applications (employee_id, leave_type_id, company_id, date_from, date_to, number_of_days, reason, status, manager_approved_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[data.employee_id, data.leave_type_id, data.company_id, data.date_from, data.date_to, data.number_of_days, data.reason, data.status, data.manager_id]
|
|
||||||
);
|
|
||||||
return result.insertId;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// MANAGER ACTIONS
|
|
||||||
// ==========================================
|
|
||||||
export const findPendingManagerLeaves = async (managerId: number) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT la.application_id, la.employee_id, la.leave_type_id, lt.name AS leave_type, la.date_from, la.date_to, la.number_of_days, la.reason, la.status
|
|
||||||
FROM leave_applications la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.manager_approved_by = ? AND la.status IN ('PENDING', 'PENDING_LOP') ORDER BY la.date_from ASC`,
|
|
||||||
[managerId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findLeaveForUpdate = async (applicationId: number, conn: any) => {
|
|
||||||
const [rows]: any = await conn.execute(`SELECT * FROM leave_applications WHERE application_id = ? FOR UPDATE`, [applicationId]);
|
|
||||||
return rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateLeaveStatus = async (applicationId: number, status: string, conn: any) => {
|
|
||||||
await conn.execute(`UPDATE leave_applications SET status = ? WHERE application_id = ?`, [status, applicationId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findAllocForUpdate = async (employeeId: number, leaveTypeId: number, year: number, conn: any) => {
|
|
||||||
const [rows]: any = await conn.execute(
|
|
||||||
`SELECT granted_days, used_days FROM leave_allocations WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ? AND status = 'ACTIVE' FOR UPDATE`,
|
|
||||||
[employeeId, leaveTypeId, year]
|
|
||||||
);
|
|
||||||
return rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const insertLedgerTransaction = async (data: any, conn: any) => {
|
|
||||||
await conn.execute(
|
|
||||||
`INSERT INTO leave_ledger_transactions (employee_id, leave_type_id, application_id, calendar_year, calendar_month, transaction_type, days, opening_balance, closing_balance, remarks) VALUES (?, ?, ?, ?, ?, 'DEBIT', ?, ?, ?, ?)`,
|
|
||||||
[data.employee_id, data.leave_type_id, data.application_id, data.year, data.month, data.days, data.opening_balance, data.closing_balance, data.remarks]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateAllocationUsedDays = async (employeeId: number, leaveTypeId: number, year: number, days: number, conn: any) => {
|
|
||||||
await conn.execute(`UPDATE leave_allocations SET used_days = used_days + ? WHERE employee_id = ? AND leave_type_id = ? AND calendar_year = ?`, [days, employeeId, leaveTypeId, year]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rejectLeaveApplication = async (applicationId: number, managerId: number) => {
|
|
||||||
const [result] = await pool.execute(
|
|
||||||
`UPDATE leave_applications SET status = 'REJECTED' WHERE application_id = ? AND manager_approved_by = ? AND status IN ('PENDING', 'PENDING_LOP')`,
|
|
||||||
[applicationId, managerId]
|
|
||||||
);
|
|
||||||
return result.affectedRows > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REPORTS
|
|
||||||
// ==========================================
|
|
||||||
export const findLedgerReportData = async (year: number, month: number, companyId: number) => {
|
|
||||||
const [rows] = await pool.execute(
|
|
||||||
`SELECT la.employee_id, lt.name AS leave_type, la.granted_days AS total_granted, la.used_days AS total_used,
|
|
||||||
COALESCE((SELECT t_ob.closing_balance FROM leave_ledger_transactions t_ob WHERE t_ob.employee_id = la.employee_id AND t_ob.leave_type_id = la.leave_type_id AND (t_ob.calendar_year < ? OR (t_ob.calendar_year = ? AND t_ob.calendar_month < ?)) ORDER BY t_ob.transaction_date DESC LIMIT 1), la.granted_days) AS opening_balance,
|
|
||||||
COALESCE((SELECT t_cb.closing_balance FROM leave_ledger_transactions t_cb WHERE t_cb.employee_id = la.employee_id AND t_cb.leave_type_id = la.leave_type_id AND (t_cb.calendar_year < ? OR (t_cb.calendar_year = ? AND t_cb.calendar_month <= ?)) ORDER BY t_cb.transaction_date DESC LIMIT 1), la.granted_days) AS closing_balance
|
|
||||||
FROM leave_allocations la INNER JOIN leave_types lt ON la.leave_type_id = lt.leave_type_id
|
|
||||||
WHERE la.calendar_year = ? AND la.company_id = ?`,
|
|
||||||
[year, year, month, year, year, month, year, companyId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
// services/lms/routes.ts
|
|
||||||
import { Router } from "@oak/oak";
|
|
||||||
import { requireAuth, requireRole, AppRole } from "core/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();
|
|
||||||
|
|
||||||
// 1. Employee Leave Actions
|
|
||||||
router.post("/lms/leaves/apply", requireAuth, applyForLeave);
|
|
||||||
router.get("/lms/leaves/balances", requireAuth, getLeaveBalances);
|
|
||||||
router.get("/lms/leaves/history", requireAuth, getLeaveHistory);
|
|
||||||
router.get("/lms/holidays/valid-optional", requireAuth, getValidOptionalHolidays);
|
|
||||||
|
|
||||||
// 2. Manager Actions
|
|
||||||
router.get("/lms/manager/pending", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), getPendingManagerLeaves);
|
|
||||||
router.post("/lms/manager/leaves/:applicationId/approve", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), approveLeaveManager);
|
|
||||||
router.post("/lms/manager/leaves/:applicationId/reject", requireAuth, requireRole([AppRole.MANAGER, AppRole.HR_MANAGER, AppRole.DIRECTOR]), rejectLeaveManager);
|
|
||||||
|
|
||||||
// 3. Admin Setup & Configuration
|
|
||||||
router.post("/lms/config/types", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createLeaveType);
|
|
||||||
router.get("/lms/config/types", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), getLeaveTypes);
|
|
||||||
router.post("/lms/config/rules", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createPolicyRule);
|
|
||||||
router.post("/lms/config/holidays", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), createCompanyHoliday);
|
|
||||||
router.put("/lms/config/work-settings", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), updateWorkSettings);
|
|
||||||
|
|
||||||
// 4. Admin / HR Reporting
|
|
||||||
router.get("/lms/admin/reports/ob-cb", requireAuth, requireRole([AppRole.HR_MANAGER, AppRole.DIRECTOR]), getLedgerReport);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
// services/lms/services/admin.service.ts
|
|
||||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
|
||||||
|
|
||||||
export const createLeaveType = async (data: any) => {
|
|
||||||
return await LmsRepo.insertLeaveType(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLeaveTypes = async (companyId: number) => {
|
|
||||||
return await LmsRepo.findLeaveTypes(companyId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createPolicyRule = async (data: any) => {
|
|
||||||
return await LmsRepo.insertPolicyRule(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createCompanyHoliday = async (body: any[]) => {
|
|
||||||
if (!Array.isArray(body)) throw new Error("Expected an array of holidays.");
|
|
||||||
const values = body.map(h => [h.company_id, h.branch_id || null, h.calendar_year, h.holiday_date, h.holiday_type || 'MANDATORY', h.holiday_name]);
|
|
||||||
return await LmsRepo.bulkInsertHolidays(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateWorkSettings = async (data: any) => {
|
|
||||||
const offDaysJson = JSON.stringify(data.weekly_off_days);
|
|
||||||
return await LmsRepo.upsertWorkSettings({ ...data, offDaysJson });
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// services/lms/services/employee.service.ts
|
|
||||||
import { AppRole } from "core/auth.ts";
|
|
||||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
|
||||||
|
|
||||||
export const getLeaveBalances = async (user: any, params: any) => {
|
|
||||||
let targetEmployeeId = user.employee_id;
|
|
||||||
if ((user.role === AppRole.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();
|
|
||||||
return await LmsRepo.findLeaveBalances(targetEmployeeId, year);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLeaveHistory = async (user: any, params: any) => {
|
|
||||||
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();
|
|
||||||
return await LmsRepo.findLeaveHistory(targetEmployeeId, year);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getValidOptionalHolidays = async (user: any) => {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const companyId = user.company_id ?? null;
|
|
||||||
const branchId = user.branch_id ?? null;
|
|
||||||
return await LmsRepo.findOptionalHolidays(companyId, branchId, currentYear, today);
|
|
||||||
};
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
// services/lms/services/leave.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { calculateLeaveDays } from "core/calendar.service.ts";
|
|
||||||
import { getEmployeeManagerId } from "core/internal-client.ts";
|
|
||||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
|
||||||
|
|
||||||
export const applyForLeave = async (payload: any) => {
|
|
||||||
const currentYear = new Date(payload.date_from).getFullYear();
|
|
||||||
const currentMonth = new Date(payload.date_from).getMonth() + 1;
|
|
||||||
|
|
||||||
// 1. Fetch Config & Rules
|
|
||||||
const branchSettingsRaw = await LmsRepo.findWorkSettings(payload.company_id, payload.branch_id);
|
|
||||||
const workSettings = { weeklyOffDays: branchSettingsRaw?.weekly_off_days || [0] };
|
|
||||||
|
|
||||||
const holidays = await LmsRepo.findHolidays(payload.company_id, currentYear, payload.branch_id);
|
|
||||||
const policy = await LmsRepo.findPolicyRule(payload.leave_type_id, payload.company_id, currentYear, payload.branch_id);
|
|
||||||
|
|
||||||
if (!policy) throw new Error("Leave policy not configured for this type/year.");
|
|
||||||
|
|
||||||
// 2. Calculate Requested Days
|
|
||||||
let requestedDays = 0;
|
|
||||||
if (payload.is_half_day) {
|
|
||||||
if (payload.date_from !== payload.date_to) throw new Error("Half-day leaves must be on the same date.");
|
|
||||||
requestedDays = 0.5;
|
|
||||||
} else {
|
|
||||||
requestedDays = calculateLeaveDays(payload.date_from, payload.date_to, workSettings, holidays, policy.apply_sandwich_policy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedDays === 0) throw new Error("Selected dates fall entirely on weekends/holidays with no sandwich policy applied.");
|
|
||||||
|
|
||||||
// 3. Fetch Balances & Limits
|
|
||||||
const rawBalance = Number(await LmsRepo.findRawBalance(payload.employee_id, payload.leave_type_id, currentYear) || 0);
|
|
||||||
const pendingDays = await LmsRepo.findPendingDays(payload.employee_id, payload.leave_type_id, currentYear);
|
|
||||||
const availableBalance = rawBalance - pendingDays;
|
|
||||||
const usedThisMonth = await LmsRepo.findMonthlyUsage(payload.employee_id, payload.leave_type_id, currentMonth, currentYear);
|
|
||||||
|
|
||||||
// 4. Determine Paid vs LOP Split
|
|
||||||
let paidDays = 0, lopDays = 0;
|
|
||||||
const remainingMonthlyLimit = policy.max_days_per_month !== null ? Math.max(0, policy.max_days_per_month - usedThisMonth) : requestedDays;
|
|
||||||
const maxAllowedPaid = Math.min(availableBalance, remainingMonthlyLimit);
|
|
||||||
|
|
||||||
if (requestedDays <= maxAllowedPaid) {
|
|
||||||
paidDays = requestedDays;
|
|
||||||
} else {
|
|
||||||
paidDays = maxAllowedPaid;
|
|
||||||
lopDays = requestedDays - paidDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Fetch Manager ID via HTTP
|
|
||||||
const managerId = await getEmployeeManagerId(payload.employee_id);
|
|
||||||
|
|
||||||
// 6. Execute DB Transaction
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const insertedIds = [];
|
|
||||||
|
|
||||||
if (paidDays > 0) {
|
|
||||||
const id = await LmsRepo.insertLeaveApplication({ ...payload, number_of_days: paidDays, status: 'PENDING', manager_id: managerId }, connection);
|
|
||||||
insertedIds.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lopDays > 0) {
|
|
||||||
const id = await LmsRepo.insertLeaveApplication({ ...payload, number_of_days: lopDays, status: 'PENDING_LOP', manager_id: managerId }, connection);
|
|
||||||
insertedIds.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
return { total_requested: requestedDays, paid_days: paidDays, lop_days: lopDays, application_ids: insertedIds };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
// services/lms/services/manager.service.ts
|
|
||||||
import pool from "../db.ts";
|
|
||||||
import { getEmployeeRoster } from "core/internal-client.ts";
|
|
||||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
|
||||||
|
|
||||||
export const getPendingManagerLeaves = async (user: any) => {
|
|
||||||
const rows = await LmsRepo.findPendingManagerLeaves(user.employee_id);
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
|
|
||||||
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
|
|
||||||
const roster = await getEmployeeRoster(employeeIds);
|
|
||||||
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
|
|
||||||
|
|
||||||
return rows.map((app: any) => {
|
|
||||||
const emp = rosterMap.get(app.employee_id);
|
|
||||||
return {
|
|
||||||
...app,
|
|
||||||
employee_code: emp?.employee_code || "N/A",
|
|
||||||
first_name: emp?.full_name?.split(" ")[0] || "",
|
|
||||||
last_name: emp?.full_name?.split(" ").slice(1).join(" ") || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const approveLeaveManager = async (user: any, applicationId: number) => {
|
|
||||||
const connection = await pool.getConnection();
|
|
||||||
try {
|
|
||||||
await connection.beginTransaction();
|
|
||||||
const leave = await LmsRepo.findLeaveForUpdate(applicationId, connection);
|
|
||||||
|
|
||||||
if (!leave) throw new Error("Leave application not found.");
|
|
||||||
if (leave.manager_approved_by !== user.employee_id) throw new Error("Access Denied: You are not the assigned manager.");
|
|
||||||
if (!['PENDING', 'PENDING_LOP'].includes(leave.status)) throw new Error(`Leave is already processed. Current status: ${leave.status}`);
|
|
||||||
|
|
||||||
const newStatus = leave.status === 'PENDING_LOP' ? 'APPROVED_LOP' : 'APPROVED';
|
|
||||||
await LmsRepo.updateLeaveStatus(applicationId, newStatus, connection);
|
|
||||||
|
|
||||||
if (newStatus === 'APPROVED') {
|
|
||||||
const currentYear = new Date(leave.date_from).getFullYear();
|
|
||||||
const currentMonth = new Date(leave.date_from).getMonth() + 1;
|
|
||||||
|
|
||||||
const alloc = await LmsRepo.findAllocForUpdate(leave.employee_id, leave.leave_type_id, currentYear, connection);
|
|
||||||
if (alloc) {
|
|
||||||
const openingBalance = Number(alloc.granted_days) - Number(alloc.used_days);
|
|
||||||
const closingBalance = openingBalance - Number(leave.number_of_days);
|
|
||||||
|
|
||||||
await LmsRepo.insertLedgerTransaction({
|
|
||||||
employee_id: leave.employee_id, leave_type_id: leave.leave_type_id, application_id: applicationId,
|
|
||||||
year: currentYear, month: currentMonth, days: leave.number_of_days,
|
|
||||||
opening_balance: openingBalance, closing_balance: closingBalance,
|
|
||||||
remarks: `Approved by Manager ID: ${user.employee_id}`
|
|
||||||
}, connection);
|
|
||||||
|
|
||||||
await LmsRepo.updateAllocationUsedDays(leave.employee_id, leave.leave_type_id, currentYear, leave.number_of_days, connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.commit();
|
|
||||||
return { new_status: newStatus };
|
|
||||||
} catch (error) {
|
|
||||||
await connection.rollback();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rejectLeaveManager = async (user: any, applicationId: number) => {
|
|
||||||
const success = await LmsRepo.rejectLeaveApplication(applicationId, user.employee_id);
|
|
||||||
if (!success) throw new Error("Leave not found, already processed, or you lack permission.");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
// services/lms/services/reports.service.ts
|
|
||||||
import { getEmployeeRoster } from "core/internal-client.ts";
|
|
||||||
import * as LmsRepo from "../repositories/lms.repository.ts";
|
|
||||||
|
|
||||||
export const getLedgerReport = async (month: number, year: number, companyId: number) => {
|
|
||||||
const rows = await LmsRepo.findLedgerReportData(year, month, companyId);
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
|
|
||||||
const employeeIds = [...new Set(rows.map((r: any) => r.employee_id))];
|
|
||||||
const roster = await getEmployeeRoster(employeeIds);
|
|
||||||
const rosterMap = new Map(roster.map((e: any) => [e.employee_id, e]));
|
|
||||||
|
|
||||||
return rows.map((row: any) => {
|
|
||||||
const emp = rosterMap.get(row.employee_id);
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
employee_code: emp?.employee_code || "N/A",
|
|
||||||
employee_name: emp?.full_name || "Unknown",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
65
shared/auth.ts
Normal file
65
shared/auth.ts
Normal 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();
|
||||||
|
};
|
||||||
|
};
|
||||||
23
shared/db.ts
Normal file
23
shared/db.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 3306,
|
||||||
|
user: "admin",
|
||||||
|
password: "admin123",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pools: Record<string, mysql.Pool> = {};
|
||||||
|
|
||||||
|
export const getDbPool = (databaseName: string): mysql.Pool => {
|
||||||
|
if (!pools[databaseName]) {
|
||||||
|
pools[databaseName] = mysql.createPool({
|
||||||
|
...dbConfig,
|
||||||
|
database: databaseName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pools[databaseName];
|
||||||
|
};
|
||||||
8
shared/deno.json
Normal file
8
shared/deno.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@shared/utils",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"exports": {
|
||||||
|
"./db": "./db.ts",
|
||||||
|
"./types": "./types.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
// services/ems/sequence.ts
|
// shared/sequence.ts
|
||||||
import pool from "./db.ts";
|
import { getDbPool } from "./db.ts";
|
||||||
|
|
||||||
|
const pool = getDbPool("hrms_ems");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a sequential code for a given entity.
|
* Generates a sequential code for a given entity.
|
||||||
74
start-dev.sh
74
start-dev.sh
@ -1,74 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# HRMS Local Development Startup Script
|
|
||||||
|
|
||||||
# 1. Load environment variables from .env
|
|
||||||
if [ -f .env ]; then
|
|
||||||
# 'set -a' automatically exports all variables loaded from the .env file
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
else
|
|
||||||
echo "❌ Error: .env file not found in the current directory!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# List of all available services
|
|
||||||
ALL_SERVICES=("gateway" "ems" "ams" "lms")
|
|
||||||
|
|
||||||
# Determine which services to run based on user input
|
|
||||||
if [ $# -eq 0 ]; then
|
|
||||||
SERVICES_TO_RUN=("${ALL_SERVICES[@]}")
|
|
||||||
else
|
|
||||||
SERVICES_TO_RUN=("$@")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Variables to track running processes
|
|
||||||
PIDS=""
|
|
||||||
ACTIVE_SERVICES=()
|
|
||||||
|
|
||||||
echo "🚀 Starting Deno Microservices..."
|
|
||||||
|
|
||||||
# 2. Start the requested services
|
|
||||||
for service in "${SERVICES_TO_RUN[@]}"; do
|
|
||||||
# Convert service name to uppercase to match .env format (e.g., ems -> EMS_PORT)
|
|
||||||
upper_service=$(echo "$service" | tr '[:lower:]' '[:upper:]')
|
|
||||||
port_var="${upper_service}_PORT"
|
|
||||||
port_val="${!port_var}"
|
|
||||||
|
|
||||||
# Check if the port variable actually exists in the .env file
|
|
||||||
if [ -n "$port_val" ]; then
|
|
||||||
echo "Starting $service on port $port_val..."
|
|
||||||
deno run -A --env-file=.env "services/${service}/main.ts" &
|
|
||||||
pid=$!
|
|
||||||
PIDS="$PIDS $pid"
|
|
||||||
ACTIVE_SERVICES+=("$service")
|
|
||||||
else
|
|
||||||
echo "⚠️ Warning: Unknown service '$service' or missing ${port_var} in .env. Skipping."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Check if anything actually started
|
|
||||||
if [ -z "$PIDS" ]; then
|
|
||||||
echo "❌ No valid services started. Exiting."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Print the dynamic port mapping using .env variables
|
|
||||||
echo "✅ Selected services are up!"
|
|
||||||
echo "----------------------------------------"
|
|
||||||
for service in "${ACTIVE_SERVICES[@]}"; do
|
|
||||||
upper_service=$(echo "$service" | tr '[:lower:]' '[:upper:]')
|
|
||||||
port_var="${upper_service}_PORT"
|
|
||||||
|
|
||||||
# Print nicely formatted columns
|
|
||||||
printf "%-10s http://localhost:%s\n" "$service:" "${!port_var}"
|
|
||||||
done
|
|
||||||
echo "----------------------------------------"
|
|
||||||
echo "Press CTRL+C to stop the running services."
|
|
||||||
|
|
||||||
# 4. Trap CTRL+C to kill ONLY the processes we just started
|
|
||||||
trap "echo -e '\n🛑 Shutting down services...' && kill $PIDS 2>/dev/null; exit" SIGINT SIGTERM
|
|
||||||
|
|
||||||
# Keep the script running so logs are visible
|
|
||||||
wait
|
|
||||||
Loading…
x
Reference in New Issue
Block a user