Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
319 lines
9.1 KiB
JavaScript
319 lines
9.1 KiB
JavaScript
const db = require('../config/database');
|
|
const { getSetting, getCurrentDateInTimezone, parseDateInTimezone } = require('../config/settingsManager');
|
|
|
|
/**
|
|
* ATTENDANCE SERVICE
|
|
* Mengelola absensi karyawan (teknisi, admin, cashier, collector)
|
|
*/
|
|
|
|
// Calculate distance between two coordinates (Haversine formula)
|
|
function calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371; // Radius of Earth in kilometers
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a =
|
|
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
const distance = R * c * 1000; // Convert to meters
|
|
return distance;
|
|
}
|
|
|
|
// Validate if location is within allowed radius
|
|
function validateLocation(lat, lng) {
|
|
// Get office location from settings
|
|
const officeLat = parseFloat(getSetting('office_lat', '0'));
|
|
const officeLng = parseFloat(getSetting('office_lng', '0'));
|
|
const allowedRadius = parseInt(getSetting('attendance_radius', '100')); // Default 100 meters
|
|
const geofencingEnabled = getSetting('attendance_geofencing', 'true') === 'true';
|
|
|
|
// If geofencing disabled or office location not set, allow all
|
|
if (!geofencingEnabled || !officeLat || !officeLng) {
|
|
return { valid: true, distance: 0, message: 'Geofencing disabled' };
|
|
}
|
|
|
|
// If no GPS provided
|
|
if (!lat || !lng) {
|
|
return { valid: false, distance: 0, message: 'GPS location required' };
|
|
}
|
|
|
|
// Calculate distance
|
|
const distance = calculateDistance(officeLat, officeLng, parseFloat(lat), parseFloat(lng));
|
|
|
|
// Check if within radius
|
|
if (distance <= allowedRadius) {
|
|
return {
|
|
valid: true,
|
|
distance: Math.round(distance),
|
|
message: `Within range (${Math.round(distance)}m from office)`
|
|
};
|
|
} else {
|
|
return {
|
|
valid: false,
|
|
distance: Math.round(distance),
|
|
message: `Too far from office (${Math.round(distance)}m, max ${allowedRadius}m)`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create attendance record (check-in)
|
|
function checkIn(data) {
|
|
// Validate location
|
|
const locationCheck = validateLocation(data.lat, data.lng);
|
|
if (!locationCheck.valid) {
|
|
throw new Error(locationCheck.message);
|
|
}
|
|
const stmt = db.prepare(`
|
|
INSERT INTO attendance (
|
|
employee_type, employee_id, employee_name,
|
|
check_in_time, check_in_lat, check_in_lng, check_in_note, check_in_photo
|
|
) VALUES (?, ?, ?, NOW_LOCAL(), ?, ?, ?, ?)
|
|
`);
|
|
|
|
return stmt.run(
|
|
data.employee_type,
|
|
data.employee_id,
|
|
data.employee_name,
|
|
data.lat || '',
|
|
data.lng || '',
|
|
data.note || '',
|
|
data.photo || ''
|
|
);
|
|
}
|
|
|
|
// Update attendance record (check-out)
|
|
function checkOut(attendanceId, data) {
|
|
const attendance = db.prepare('SELECT * FROM attendance WHERE id = ?').get(attendanceId);
|
|
if (!attendance) {
|
|
throw new Error('Attendance record not found');
|
|
}
|
|
|
|
if (attendance.status === 'checked_out') {
|
|
throw new Error('Already checked out');
|
|
}
|
|
|
|
// Validate location for check-out
|
|
const locationCheck = validateLocation(data.lat, data.lng);
|
|
if (!locationCheck.valid) {
|
|
throw new Error(locationCheck.message);
|
|
}
|
|
|
|
// Calculate work duration in minutes
|
|
const checkInTime = parseDateInTimezone(attendance.check_in_time);
|
|
const checkOutTime = new Date();
|
|
const durationMinutes = Math.floor((checkOutTime - checkInTime) / 1000 / 60);
|
|
|
|
const stmt = db.prepare(`
|
|
UPDATE attendance
|
|
SET check_out_time = NOW_LOCAL(),
|
|
check_out_lat = ?,
|
|
check_out_lng = ?,
|
|
check_out_note = ?,
|
|
check_out_photo = ?,
|
|
work_duration_minutes = ?,
|
|
status = 'checked_out'
|
|
WHERE id = ?
|
|
`);
|
|
|
|
return stmt.run(
|
|
data.lat || '',
|
|
data.lng || '',
|
|
data.note || '',
|
|
data.photo || '',
|
|
durationMinutes,
|
|
attendanceId
|
|
);
|
|
}
|
|
|
|
// Get today's attendance for an employee
|
|
function getTodayAttendance(employeeType, employeeId) {
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE employee_type = ?
|
|
AND employee_id = ?
|
|
AND date(check_in_time) = date(NOW_LOCAL())
|
|
ORDER BY check_in_time DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
return stmt.get(employeeType, employeeId);
|
|
}
|
|
|
|
// Get attendance history for an employee
|
|
function getAttendanceHistory(employeeType, employeeId, limit = 30) {
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE employee_type = ? AND employee_id = ?
|
|
ORDER BY check_in_time DESC
|
|
LIMIT ?
|
|
`);
|
|
|
|
return stmt.all(employeeType, employeeId, limit);
|
|
}
|
|
|
|
// Get all attendance records for a specific date
|
|
function getAttendanceByDate(date) {
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE date(check_in_time) = date(?)
|
|
ORDER BY check_in_time DESC
|
|
`);
|
|
|
|
return stmt.all(date);
|
|
}
|
|
|
|
// Get attendance records for a date range
|
|
function getAttendanceByDateRange(startDate, endDate) {
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE date(check_in_time) BETWEEN date(?) AND date(?)
|
|
ORDER BY check_in_time DESC
|
|
`);
|
|
|
|
return stmt.all(startDate, endDate);
|
|
}
|
|
|
|
// Get attendance summary for an employee (monthly)
|
|
function getMonthlyAttendanceSummary(employeeType, employeeId, year, month) {
|
|
const stmt = db.prepare(`
|
|
SELECT
|
|
COUNT(*) as total_days,
|
|
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as completed_days,
|
|
SUM(work_duration_minutes) as total_minutes,
|
|
AVG(work_duration_minutes) as avg_minutes
|
|
FROM attendance
|
|
WHERE employee_type = ?
|
|
AND employee_id = ?
|
|
AND strftime('%Y', check_in_time) = ?
|
|
AND strftime('%m', check_in_time) = ?
|
|
`);
|
|
|
|
const yearStr = String(year);
|
|
const monthStr = String(month).padStart(2, '0');
|
|
|
|
return stmt.get(employeeType, employeeId, yearStr, monthStr);
|
|
}
|
|
|
|
// Get all attendance for today (for admin dashboard)
|
|
function getTodayAllAttendance() {
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE date(check_in_time) = date(NOW_LOCAL())
|
|
ORDER BY check_in_time DESC
|
|
`);
|
|
|
|
return stmt.all();
|
|
}
|
|
|
|
// Get attendance statistics for admin
|
|
function getAttendanceStats(date = null) {
|
|
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT
|
|
employee_type,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = 'checked_in' THEN 1 ELSE 0 END) as checked_in,
|
|
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as checked_out
|
|
FROM attendance
|
|
WHERE ${dateFilter}
|
|
GROUP BY employee_type
|
|
`);
|
|
|
|
return stmt.all();
|
|
}
|
|
|
|
// Check if employee has checked in today
|
|
function hasCheckedInToday(employeeType, employeeId) {
|
|
const today = getTodayAttendance(employeeType, employeeId);
|
|
return today !== undefined;
|
|
}
|
|
|
|
// Get late check-ins (after 8:30 AM)
|
|
function getLateCheckIns(date = null) {
|
|
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE ${dateFilter}
|
|
AND time(check_in_time) > '08:30:00'
|
|
ORDER BY check_in_time DESC
|
|
`);
|
|
|
|
return stmt.all();
|
|
}
|
|
|
|
// Get employees who haven't checked out
|
|
function getNotCheckedOut(date = null) {
|
|
const dateFilter = date ? `date(check_in_time) = date('${date}')` : `date(check_in_time) = date(NOW_LOCAL())`;
|
|
|
|
const stmt = db.prepare(`
|
|
SELECT * FROM attendance
|
|
WHERE ${dateFilter}
|
|
AND status = 'checked_in'
|
|
ORDER BY check_in_time DESC
|
|
`);
|
|
|
|
return stmt.all();
|
|
}
|
|
|
|
// Delete attendance record (admin only)
|
|
function deleteAttendance(id) {
|
|
return db.prepare('DELETE FROM attendance WHERE id = ?').run(id);
|
|
}
|
|
|
|
// Update attendance record (admin only - for corrections)
|
|
function updateAttendance(id, data) {
|
|
const stmt = db.prepare(`
|
|
UPDATE attendance
|
|
SET check_in_time = ?,
|
|
check_in_note = ?,
|
|
check_out_time = ?,
|
|
check_out_note = ?,
|
|
work_duration_minutes = ?
|
|
WHERE id = ?
|
|
`);
|
|
|
|
return stmt.run(
|
|
data.check_in_time,
|
|
data.check_in_note || '',
|
|
data.check_out_time || null,
|
|
data.check_out_note || '',
|
|
data.work_duration_minutes || 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
// Get geofencing settings
|
|
function getGeofencingSettings() {
|
|
return {
|
|
enabled: getSetting('attendance_geofencing', 'true') === 'true',
|
|
officeLat: parseFloat(getSetting('office_lat', '0')),
|
|
officeLng: parseFloat(getSetting('office_lng', '0')),
|
|
radius: parseInt(getSetting('attendance_radius', '100'))
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
checkIn,
|
|
checkOut,
|
|
getTodayAttendance,
|
|
getAttendanceHistory,
|
|
getAttendanceByDate,
|
|
getAttendanceByDateRange,
|
|
getMonthlyAttendanceSummary,
|
|
getTodayAllAttendance,
|
|
getAttendanceStats,
|
|
hasCheckedInToday,
|
|
getLateCheckIns,
|
|
getNotCheckedOut,
|
|
deleteAttendance,
|
|
updateAttendance,
|
|
validateLocation,
|
|
calculateDistance,
|
|
getGeofencingSettings
|
|
};
|
|
|
|
// Made with Bob
|