512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { Router, type RouterContext, type State } from "@oak/oak";
|
|
import { db } from "../config/database.ts";
|
|
import {
|
|
authenticateToken,
|
|
authorize,
|
|
getCurrentUser,
|
|
} from "../middleware/auth.ts";
|
|
import type {
|
|
Attendance,
|
|
AttendanceStatus,
|
|
CheckInOutRequest,
|
|
JWTPayload,
|
|
UpdateAttendanceStatusRequest,
|
|
User,
|
|
} from "../types/index.ts";
|
|
|
|
const router = new Router();
|
|
|
|
// Get all attendance records
|
|
router.get(
|
|
"/",
|
|
authenticateToken,
|
|
async (
|
|
ctx: RouterContext<"/", Record<string | number, string | undefined>, State>,
|
|
) => {
|
|
try {
|
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
|
const employeeId: string | null = params.get("employeeId");
|
|
const startDate: string | null = params.get("startDate");
|
|
const endDate: string | null = params.get("endDate");
|
|
const status: string | null = params.get("status");
|
|
|
|
let query = `
|
|
SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE 1=1
|
|
`;
|
|
const queryParams: unknown[] = [];
|
|
|
|
// Role-based filtering
|
|
if (currentUser.role === "Supervisor") {
|
|
query += " AND a.supervisor_id = ?";
|
|
queryParams.push(currentUser.id);
|
|
} else if (currentUser.role === "Employee") {
|
|
query += " AND a.employee_id = ?";
|
|
queryParams.push(currentUser.id);
|
|
}
|
|
|
|
if (employeeId) {
|
|
query += " AND a.employee_id = ?";
|
|
queryParams.push(employeeId);
|
|
}
|
|
|
|
if (startDate) {
|
|
query += " AND a.work_date >= ?";
|
|
queryParams.push(startDate);
|
|
}
|
|
|
|
if (endDate) {
|
|
query += " AND a.work_date <= ?";
|
|
queryParams.push(endDate);
|
|
}
|
|
|
|
if (status) {
|
|
query += " AND a.status = ?";
|
|
queryParams.push(status);
|
|
}
|
|
|
|
query += " ORDER BY a.work_date DESC, a.check_in_time DESC";
|
|
|
|
const records = await db.query<Attendance[]>(query, queryParams);
|
|
ctx.response.body = records;
|
|
} catch (error) {
|
|
console.error("Get attendance error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get attendance by ID
|
|
router.get("/:id", authenticateToken, async (ctx) => {
|
|
try {
|
|
const attendanceId = ctx.params.id;
|
|
|
|
const records = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[attendanceId],
|
|
);
|
|
|
|
if (records.length === 0) {
|
|
ctx.response.status = 404;
|
|
ctx.response.body = { error: "Attendance record not found" };
|
|
return;
|
|
}
|
|
|
|
ctx.response.body = records[0];
|
|
} catch (error) {
|
|
console.error("Get attendance error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
});
|
|
|
|
// Check in employee (Supervisor or SuperAdmin)
|
|
router.post(
|
|
"/check-in",
|
|
authenticateToken,
|
|
authorize("Supervisor", "SuperAdmin"),
|
|
async (ctx) => {
|
|
try {
|
|
const currentUser = getCurrentUser(ctx);
|
|
const body = await ctx.request.body.json() as CheckInOutRequest;
|
|
const { employeeId, workDate } = body;
|
|
|
|
if (!employeeId || !workDate) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "Employee ID and work date required" };
|
|
return;
|
|
}
|
|
|
|
// Verify user exists and is Employee or Contractor
|
|
let userQuery = "SELECT * FROM users WHERE id = ? AND role IN ('Employee', 'Contractor')";
|
|
const userParams: unknown[] = [employeeId];
|
|
|
|
if (currentUser.role === "Supervisor") {
|
|
userQuery += " AND department_id = ?";
|
|
userParams.push(currentUser.departmentId);
|
|
}
|
|
|
|
const users = await db.query<User[]>(userQuery, userParams);
|
|
|
|
if (users.length === 0) {
|
|
ctx.response.status = 403;
|
|
ctx.response.body = {
|
|
error: "User not found, not an Employee/Contractor, or not in your department",
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Check if there's any existing attendance record for today
|
|
const existingRecord = await db.query<Attendance[]>(
|
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
|
|
[employeeId, workDate],
|
|
);
|
|
|
|
const checkInTime = new Date().toISOString().slice(0, 19).replace(
|
|
"T",
|
|
" ",
|
|
);
|
|
|
|
let recordId: number;
|
|
|
|
if (existingRecord.length > 0) {
|
|
const record = existingRecord[0];
|
|
|
|
// If already checked in, don't allow another check-in
|
|
if (record.status === "CheckedIn") {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "User has an active check-in. Please check out first before checking in again." };
|
|
return;
|
|
}
|
|
|
|
// If checked out or other status, update the existing record to check in again
|
|
await db.execute(
|
|
"UPDATE attendance SET check_in_time = ?, check_out_time = NULL, status = ?, supervisor_id = ? WHERE id = ?",
|
|
[checkInTime, "CheckedIn", currentUser.id, record.id],
|
|
);
|
|
recordId = record.id;
|
|
} else {
|
|
// No existing record, create new one
|
|
const result = await db.execute(
|
|
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
|
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"],
|
|
);
|
|
recordId = result.insertId as number;
|
|
}
|
|
|
|
const newRecord = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[recordId],
|
|
);
|
|
|
|
ctx.response.status = existingRecord.length > 0 ? 200 : 201;
|
|
ctx.response.body = newRecord[0];
|
|
} catch (error) {
|
|
console.error("Check in error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
// Check out employee (Supervisor or SuperAdmin)
|
|
router.post(
|
|
"/check-out",
|
|
authenticateToken,
|
|
authorize("Supervisor", "SuperAdmin"),
|
|
async (ctx) => {
|
|
try {
|
|
const currentUser = getCurrentUser(ctx);
|
|
const body = await ctx.request.body.json() as CheckInOutRequest;
|
|
const { employeeId, workDate } = body;
|
|
|
|
if (!employeeId || !workDate) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "Employee ID and work date required" };
|
|
return;
|
|
}
|
|
|
|
// Find the check-in record
|
|
let query =
|
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
|
|
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
|
|
|
|
if (currentUser.role === "Supervisor") {
|
|
query += " AND supervisor_id = ?";
|
|
params.push(currentUser.id);
|
|
}
|
|
|
|
const records = await db.query<Attendance[]>(query, params);
|
|
|
|
if (records.length === 0) {
|
|
ctx.response.status = 404;
|
|
ctx.response.body = { error: "No check-in record found for today" };
|
|
return;
|
|
}
|
|
|
|
const checkOutTime = new Date().toISOString().slice(0, 19).replace(
|
|
"T",
|
|
" ",
|
|
);
|
|
|
|
await db.execute(
|
|
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
|
|
[checkOutTime, "CheckedOut", records[0].id],
|
|
);
|
|
|
|
const updatedRecord = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[records[0].id],
|
|
);
|
|
|
|
ctx.response.body = updatedRecord[0];
|
|
} catch (error) {
|
|
console.error("Check out error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
// Update attendance status (mark as Absent, HalfDay, Late)
|
|
router.put(
|
|
"/:id/status",
|
|
authenticateToken,
|
|
authorize("Supervisor", "SuperAdmin"),
|
|
async (ctx) => {
|
|
try {
|
|
const attendanceId = ctx.params.id;
|
|
const body = await ctx.request.body
|
|
.json() as UpdateAttendanceStatusRequest;
|
|
const { status, remark } = body;
|
|
|
|
// Validate status
|
|
const validStatuses: AttendanceStatus[] = [
|
|
"CheckedIn",
|
|
"CheckedOut",
|
|
"Absent",
|
|
"HalfDay",
|
|
"Late",
|
|
];
|
|
if (!validStatuses.includes(status)) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = {
|
|
error:
|
|
"Invalid status. Must be one of: CheckedIn, CheckedOut, Absent, HalfDay, Late",
|
|
};
|
|
return;
|
|
}
|
|
|
|
// Check if record exists
|
|
const existing = await db.query<Attendance[]>(
|
|
"SELECT * FROM attendance WHERE id = ?",
|
|
[attendanceId],
|
|
);
|
|
|
|
if (existing.length === 0) {
|
|
ctx.response.status = 404;
|
|
ctx.response.body = { error: "Attendance record not found" };
|
|
return;
|
|
}
|
|
|
|
// Update the status
|
|
await db.execute(
|
|
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
|
[status, remark || null, attendanceId],
|
|
);
|
|
|
|
const updatedRecord = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[attendanceId],
|
|
);
|
|
|
|
ctx.response.body = updatedRecord[0];
|
|
} catch (error) {
|
|
console.error("Update attendance status error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
// Mark employee as absent (create absent record)
|
|
router.post(
|
|
"/mark-absent",
|
|
authenticateToken,
|
|
authorize("Supervisor", "SuperAdmin"),
|
|
async (ctx) => {
|
|
try {
|
|
const currentUser = getCurrentUser(ctx);
|
|
const body = await ctx.request.body.json();
|
|
const { employeeId, workDate, remark } = body;
|
|
|
|
if (!employeeId || !workDate) {
|
|
ctx.response.status = 400;
|
|
ctx.response.body = { error: "Employee ID and work date required" };
|
|
return;
|
|
}
|
|
|
|
// Check if record already exists for this date
|
|
const existing = await db.query<Attendance[]>(
|
|
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
|
|
[employeeId, workDate],
|
|
);
|
|
|
|
if (existing.length > 0) {
|
|
// Update existing record to Absent
|
|
await db.execute(
|
|
"UPDATE attendance SET status = ?, remark = ? WHERE id = ?",
|
|
["Absent", remark || "Marked absent", existing[0].id],
|
|
);
|
|
|
|
const updatedRecord = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[existing[0].id],
|
|
);
|
|
|
|
ctx.response.body = updatedRecord[0];
|
|
} else {
|
|
// Create new absent record
|
|
const result = await db.execute(
|
|
"INSERT INTO attendance (employee_id, supervisor_id, work_date, status, remark) VALUES (?, ?, ?, ?, ?)",
|
|
[
|
|
employeeId,
|
|
currentUser.id,
|
|
workDate,
|
|
"Absent",
|
|
remark || "Marked absent",
|
|
],
|
|
);
|
|
|
|
const newRecord = await db.query<Attendance[]>(
|
|
`SELECT a.*,
|
|
e.name as employee_name, e.username as employee_username,
|
|
s.name as supervisor_name,
|
|
d.name as department_name,
|
|
c.name as contractor_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
JOIN users s ON a.supervisor_id = s.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
LEFT JOIN users c ON e.contractor_id = c.id
|
|
WHERE a.id = ?`,
|
|
[result.insertId],
|
|
);
|
|
|
|
ctx.response.status = 201;
|
|
ctx.response.body = newRecord[0];
|
|
}
|
|
} catch (error) {
|
|
console.error("Mark absent error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get attendance summary
|
|
router.get(
|
|
"/summary/stats",
|
|
authenticateToken,
|
|
async (
|
|
ctx: RouterContext<
|
|
"/summary/stats",
|
|
Record<string | number, string | undefined>,
|
|
State
|
|
>,
|
|
) => {
|
|
try {
|
|
const currentUser: JWTPayload = getCurrentUser(ctx);
|
|
const params: URLSearchParams = ctx.request.url.searchParams;
|
|
const startDate: string | null = params.get("startDate");
|
|
const endDate: string | null = params.get("endDate");
|
|
const departmentId: string | null = params.get("departmentId");
|
|
|
|
let query: string = `
|
|
SELECT
|
|
COUNT(DISTINCT a.employee_id) as total_employees,
|
|
COUNT(DISTINCT CASE WHEN a.status = 'CheckedIn' THEN a.employee_id END) as checked_in,
|
|
COUNT(DISTINCT CASE WHEN a.status = 'CheckedOut' THEN a.employee_id END) as checked_out,
|
|
d.name as department_name
|
|
FROM attendance a
|
|
JOIN users e ON a.employee_id = e.id
|
|
LEFT JOIN departments d ON e.department_id = d.id
|
|
WHERE 1=1
|
|
`;
|
|
const queryParams: (number | string)[] = [];
|
|
|
|
if (currentUser.role === "Supervisor") {
|
|
query += " AND a.supervisor_id = ?";
|
|
queryParams.push(currentUser.id);
|
|
}
|
|
|
|
if (startDate) {
|
|
query += " AND a.work_date >= ?";
|
|
queryParams.push(startDate);
|
|
}
|
|
|
|
if (endDate) {
|
|
query += " AND a.work_date <= ?";
|
|
queryParams.push(endDate);
|
|
}
|
|
|
|
if (departmentId) {
|
|
query += " AND e.department_id = ?";
|
|
queryParams.push(departmentId);
|
|
}
|
|
|
|
query += " GROUP BY d.id, d.name";
|
|
|
|
const summary = await db.query(query, queryParams);
|
|
ctx.response.body = summary;
|
|
} catch (error) {
|
|
console.error("Get attendance summary error:", error);
|
|
ctx.response.status = 500;
|
|
ctx.response.body = { error: "Internal server error" };
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|