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": [
|
||||
"packages/core",
|
||||
"services/ems",
|
||||
"services/ams",
|
||||
"services/lms",
|
||||
"services/gateway"
|
||||
"./shared",
|
||||
"./ems-service",
|
||||
"./ams-service",
|
||||
"./lms-service"
|
||||
],
|
||||
"imports": {
|
||||
"@oak/oak": "jsr:@oak/oak@^17.1.3",
|
||||
"mysql2": "npm:mysql2@^3.0.0",
|
||||
"core/": "./packages/core/"
|
||||
"mysql2": "npm:mysql2@^3.0.0"
|
||||
}
|
||||
}
|
||||
4
deno.lock
generated
4
deno.lock
generated
@ -89,8 +89,8 @@
|
||||
"is-property"
|
||||
]
|
||||
},
|
||||
"iconv-lite@0.7.3": {
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"iconv-lite@0.7.2": {
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"dependencies": [
|
||||
"safer-buffer"
|
||||
]
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
ems_db:
|
||||
hrms_db:
|
||||
image: docker.io/library/mysql:8.4
|
||||
container_name: hrms_ems_db
|
||||
container_name: hrms_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hrms_ems
|
||||
@ -12,41 +12,9 @@ services:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- ./init-db/01-init-ems.sql:/docker-entrypoint-initdb.d/01-init-ems.sql:Z
|
||||
- ems_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
ams_db:
|
||||
image: docker.io/library/mysql:8.4
|
||||
container_name: hrms_ams_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hrms_ams
|
||||
MYSQL_USER: admin
|
||||
MYSQL_PASSWORD: admin123
|
||||
ports:
|
||||
- "3307:3306" # Maps host 3307 to container 3306
|
||||
volumes:
|
||||
- ./init-db/02-init-ams.sql:/docker-entrypoint-initdb.d/02-init-ams.sql:Z
|
||||
- ams_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
lms_db:
|
||||
image: docker.io/library/mysql:8.4
|
||||
container_name: hrms_lms_db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hrms_lms
|
||||
MYSQL_USER: admin
|
||||
MYSQL_PASSWORD: admin123
|
||||
ports:
|
||||
- "3308:3306" # Maps host 3308 to container 3306
|
||||
volumes:
|
||||
- ./init-db/03-init-lms.sql:/docker-entrypoint-initdb.d/03-init-lms.sql:Z
|
||||
- lms_db_data:/var/lib/mysql
|
||||
- ./init-db:/docker-entrypoint-initdb.d:Z
|
||||
- hrms_db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ems_db_data:
|
||||
ams_db_data:
|
||||
lms_db_data:
|
||||
hrms_db_data:
|
||||
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 (
|
||||
regularization_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
employee_id INT NOT NULL, -- Logical Reference (The Applicant)
|
||||
branch_id INT NOT NULL,
|
||||
attendance_id INT NULL, -- Internal FK to processed ledger row
|
||||
regularization_type ENUM('MISPUNCH', 'WFH_REQUEST', 'ON_DUTY') 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
|
||||
import pool from "./db.ts";
|
||||
// shared/sequence.ts
|
||||
import { getDbPool } from "./db.ts";
|
||||
|
||||
const pool = getDbPool("hrms_ems");
|
||||
|
||||
/**
|
||||
* 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