Compare commits
2 Commits
52b2e40762
...
6187ec4afc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6187ec4afc | |||
| 1355f2e9cb |
@ -182,4 +182,338 @@ export const processDailyAttendance = async (ctx: Context) => {
|
|||||||
} finally {
|
} finally {
|
||||||
amsConnection.release();
|
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" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { getRawLogs, processDailyAttendance } from "./controllers/attendance.controller.ts";
|
import {
|
||||||
|
getRawLogs,
|
||||||
|
processDailyAttendance,
|
||||||
|
getEmployeeSummary,
|
||||||
|
getDailyReport,
|
||||||
|
getAdminRangeReport,
|
||||||
|
getSingleEmployeeRangeReport} from "./controllers/attendance.controller.ts";
|
||||||
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
import {createRegularizationRequest, reviewRegularizationRequest} from "./controllers/regularization.controller.ts"
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@ -11,6 +17,12 @@ apiV1.post("/attendance/process-daily", processDailyAttendance);
|
|||||||
apiV1.post("/attendance/regularize", createRegularizationRequest);
|
apiV1.post("/attendance/regularize", createRegularizationRequest);
|
||||||
apiV1.post("/attendance/regularize/review", reviewRegularizationRequest);
|
apiV1.post("/attendance/regularize/review", 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());
|
router.use("/api/v1", apiV1.routes(), apiV1.allowedMethods());
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
Loading…
x
Reference in New Issue
Block a user