Restrict staff attendance punching to mobile devices
This commit is contained in:
+159
-152
@@ -22,162 +22,169 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if employee %}
|
{% if employee %}
|
||||||
<div id="employee-geo-status" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
{% if is_mobile_attendance_device %}
|
||||||
Click Punch In/Punch Out. The app will ask for browser location before submitting attendance.
|
<div id="employee-geo-status" class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
</div>
|
Click Punch In/Punch Out. The app will ask for mobile browser location before submitting attendance.
|
||||||
<div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
</div>
|
||||||
When branch geofence is enabled, browser location is mandatory. If location is denied/unavailable, attendance will not be submitted.
|
<div class="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
</div>
|
Attendance marking is allowed only from mobile. Browser location/GPS is mandatory. If location is denied/unavailable, attendance will not be submitted.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-1">
|
||||||
<form method="post" action="/employee/attendance/punch-in" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
|
<form method="post" action="/employee/attendance/punch-in" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="latitude">
|
<input type="hidden" name="latitude">
|
||||||
<input type="hidden" name="longitude">
|
<input type="hidden" name="longitude">
|
||||||
<input type="hidden" name="accuracy_meters">
|
<input type="hidden" name="accuracy_meters">
|
||||||
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
||||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||||
<button class="mt-3 w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" {% if today_attendance and today_attendance.punch_in_utc %}disabled{% endif %}>Punch In</button>
|
<button class="mt-3 w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" {% if today_attendance and today_attendance.punch_in_utc %}disabled{% endif %}>Punch In</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/employee/attendance/punch-out" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
|
<form method="post" action="/employee/attendance/punch-out" class="employee-attendance-geo-form rounded-2xl border border-slate-200 p-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="latitude">
|
<input type="hidden" name="latitude">
|
||||||
<input type="hidden" name="longitude">
|
<input type="hidden" name="longitude">
|
||||||
<input type="hidden" name="accuracy_meters">
|
<input type="hidden" name="accuracy_meters">
|
||||||
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
<label class="block text-xs font-semibold uppercase text-slate-500">Remarks / OD Reason</label>
|
||||||
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
<input name="remarks" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
||||||
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
|
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="employee-location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 px-4" aria-hidden="true">
|
<div id="employee-location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 px-4" aria-hidden="true">
|
||||||
<div class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
|
<div class="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div>
|
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3>
|
<h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3>
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||||
To mark attendance, this app needs your current location to verify whether you are within your branch geofence.
|
To mark attendance, this app needs your current location to verify whether you are within your branch geofence.
|
||||||
Click Continue, then choose <strong>Allow</strong> in the browser location popup.
|
Click Continue, then choose <strong>Allow</strong> in the browser location popup.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-3 text-xs leading-5 text-slate-500">
|
<p class="mt-3 text-xs leading-5 text-slate-500">
|
||||||
If you are outside the branch, attendance will require approval. If browser location is denied/unavailable while branch geofence is enabled, attendance will not be submitted.
|
If you are outside the branch, attendance will require approval. If browser location is denied/unavailable while branch geofence is enabled, attendance will not be submitted.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button type="button" id="employee-location-modal-cancel" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</button>
|
||||||
|
<button type="button" id="employee-location-modal-continue" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<button type="button" id="employee-location-modal-cancel" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cancel</button>
|
|
||||||
<button type="button" id="employee-location-modal-continue" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Continue</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<script>
|
||||||
|
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
|
||||||
|
function updateStatus(message, tone) {
|
||||||
|
var status = document.getElementById('employee-geo-status');
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = 'mt-4 rounded-xl border px-3 py-2 text-xs ' + (
|
||||||
|
tone === 'ok' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' :
|
||||||
|
tone === 'warn' ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
||||||
|
'border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHidden(form, lat, lon, acc) {
|
||||||
|
var latInput = form.querySelector('input[name="latitude"]');
|
||||||
|
var lonInput = form.querySelector('input[name="longitude"]');
|
||||||
|
var accInput = form.querySelector('input[name="accuracy_meters"]');
|
||||||
|
if (latInput) latInput.value = lat || '';
|
||||||
|
if (lonInput) lonInput.value = lon || '';
|
||||||
|
if (accInput) accInput.value = acc || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLocationModal() {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var modal = document.getElementById('employee-location-consent-modal');
|
||||||
|
var modalContinue = document.getElementById('employee-location-modal-continue');
|
||||||
|
var modalCancel = document.getElementById('employee-location-modal-cancel');
|
||||||
|
if (!modal || !modalContinue || !modalCancel) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(result) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
modalContinue.removeEventListener('click', onContinue);
|
||||||
|
modalCancel.removeEventListener('click', onCancel);
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
function onContinue() { cleanup(true); }
|
||||||
|
function onCancel() {
|
||||||
|
updateStatus('Attendance punch cancelled. Location permission was not requested.', 'warn');
|
||||||
|
cleanup(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalContinue.addEventListener('click', onContinue);
|
||||||
|
modalCancel.addEventListener('click', onCancel);
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
modalContinue.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureLocation(form) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setHidden(form, '', '', '');
|
||||||
|
updateStatus('Browser geolocation is not available. Attendance was not submitted. Enable browser/device location and try again.', 'warn');
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
|
||||||
|
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||||
|
var c = pos.coords || {};
|
||||||
|
setHidden(form, c.latitude, c.longitude, c.accuracy);
|
||||||
|
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
||||||
|
resolve(true);
|
||||||
|
}, function (err) {
|
||||||
|
setHidden(form, '', '', '');
|
||||||
|
var reason = 'Location permission denied or unavailable.';
|
||||||
|
if (err && err.code === 1) reason = 'Location permission denied. Allow location for this site in browser settings.';
|
||||||
|
if (err && err.code === 2) reason = 'Location unavailable. Enable device location service and try again.';
|
||||||
|
if (err && err.code === 3) reason = 'Location capture timed out. Try again with stable GPS/location service.';
|
||||||
|
updateStatus(reason + ' Attendance was not submitted. Please allow browser location/GPS and try again.', 'warn');
|
||||||
|
resolve(false);
|
||||||
|
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
document.querySelectorAll('.employee-attendance-geo-form').forEach(function (form) {
|
||||||
|
if (form.dataset.geoBound === '1') return;
|
||||||
|
form.dataset.geoBound = '1';
|
||||||
|
form.addEventListener('submit', async function (event) {
|
||||||
|
if (form.dataset.geoSubmitted === '1') return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
var proceed = await openLocationModal();
|
||||||
|
if (!proceed) return;
|
||||||
|
|
||||||
|
var captured = await captureLocation(form);
|
||||||
|
if (!captured) {
|
||||||
|
updateStatus('Attendance not submitted because location was not captured.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.dataset.geoSubmitted = '1';
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {bind: bind};
|
||||||
|
})();
|
||||||
|
window.EmployeePortalGeo.bind();
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<div class="font-semibold">Attendance marking is mobile-only.</div>
|
||||||
|
<p class="mt-1">{{ attendance_punch_blocked_reason or 'Please open the ERP on your mobile phone to mark punch-in or punch-out. Desktop access is available only for attendance history.' }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
window.EmployeePortalGeo = window.EmployeePortalGeo || (function () {
|
|
||||||
function updateStatus(message, tone) {
|
|
||||||
var status = document.getElementById('employee-geo-status');
|
|
||||||
if (!status) return;
|
|
||||||
status.textContent = message;
|
|
||||||
status.className = 'mt-4 rounded-xl border px-3 py-2 text-xs ' + (
|
|
||||||
tone === 'ok' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' :
|
|
||||||
tone === 'warn' ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
|
||||||
'border-slate-200 bg-slate-50 text-slate-600'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setHidden(form, lat, lon, acc) {
|
|
||||||
var latInput = form.querySelector('input[name="latitude"]');
|
|
||||||
var lonInput = form.querySelector('input[name="longitude"]');
|
|
||||||
var accInput = form.querySelector('input[name="accuracy_meters"]');
|
|
||||||
if (latInput) latInput.value = lat || '';
|
|
||||||
if (lonInput) lonInput.value = lon || '';
|
|
||||||
if (accInput) accInput.value = acc || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openLocationModal() {
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
var modal = document.getElementById('employee-location-consent-modal');
|
|
||||||
var modalContinue = document.getElementById('employee-location-modal-continue');
|
|
||||||
var modalCancel = document.getElementById('employee-location-modal-cancel');
|
|
||||||
if (!modal || !modalContinue || !modalCancel) {
|
|
||||||
resolve(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(result) {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
modal.classList.remove('flex');
|
|
||||||
modal.setAttribute('aria-hidden', 'true');
|
|
||||||
modalContinue.removeEventListener('click', onContinue);
|
|
||||||
modalCancel.removeEventListener('click', onCancel);
|
|
||||||
resolve(result);
|
|
||||||
}
|
|
||||||
function onContinue() { cleanup(true); }
|
|
||||||
function onCancel() {
|
|
||||||
updateStatus('Attendance punch cancelled. Location permission was not requested.', 'warn');
|
|
||||||
cleanup(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
modalContinue.addEventListener('click', onContinue);
|
|
||||||
modalCancel.addEventListener('click', onCancel);
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
modal.classList.add('flex');
|
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
|
||||||
modalContinue.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureLocation(form) {
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
setHidden(form, '', '', '');
|
|
||||||
updateStatus('Browser geolocation is not available. Attendance was not submitted. Enable browser/device location and try again.', 'warn');
|
|
||||||
resolve(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateStatus('Browser location popup opened. Please choose Allow to capture location...', 'info');
|
|
||||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
|
||||||
var c = pos.coords || {};
|
|
||||||
setHidden(form, c.latitude, c.longitude, c.accuracy);
|
|
||||||
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
|
||||||
resolve(true);
|
|
||||||
}, function (err) {
|
|
||||||
setHidden(form, '', '', '');
|
|
||||||
var reason = 'Location permission denied or unavailable.';
|
|
||||||
if (err && err.code === 1) reason = 'Location permission denied. Allow location for this site in browser settings.';
|
|
||||||
if (err && err.code === 2) reason = 'Location unavailable. Enable device location service and try again.';
|
|
||||||
if (err && err.code === 3) reason = 'Location capture timed out. Try again with stable GPS/location service.';
|
|
||||||
updateStatus(reason + ' Attendance was not submitted. Please allow browser location/GPS and try again.', 'warn');
|
|
||||||
resolve(false);
|
|
||||||
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bind() {
|
|
||||||
document.querySelectorAll('.employee-attendance-geo-form').forEach(function (form) {
|
|
||||||
if (form.dataset.geoBound === '1') return;
|
|
||||||
form.dataset.geoBound = '1';
|
|
||||||
form.addEventListener('submit', async function (event) {
|
|
||||||
if (form.dataset.geoSubmitted === '1') return;
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
var proceed = await openLocationModal();
|
|
||||||
if (!proceed) return;
|
|
||||||
|
|
||||||
var captured = await captureLocation(form);
|
|
||||||
if (!captured) {
|
|
||||||
updateStatus('Attendance not submitted because location was not captured.', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.dataset.geoSubmitted = '1';
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {bind: bind};
|
|
||||||
})();
|
|
||||||
window.EmployeePortalGeo.bind();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||||
<h3 class="font-semibold text-slate-900">Today's Action</h3>
|
<h3 class="font-semibold text-slate-900">Today's Action</h3>
|
||||||
<p class="mt-1 text-xs text-slate-500">Location will be captured when your browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.</p>
|
{% if is_mobile_attendance_device %}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Location will be captured when your mobile browser allows it. If you are outside the branch geofence, the attendance is saved as pending approval for OD/client visit review.</p>
|
||||||
<div id="geo-status" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">Location not captured yet.</div>
|
<div id="geo-status" class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">Location not captured yet.</div>
|
||||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
<form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
||||||
@@ -59,6 +60,12 @@
|
|||||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50" {% if not today_attendance or not today_attendance.punch_in_utc or today_attendance.punch_out_utc %}disabled{% endif %}>Punch Out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<div class="font-semibold">Attendance marking is mobile-only.</div>
|
||||||
|
<p class="mt-1">{{ attendance_punch_blocked_reason or 'Please open the ERP on your mobile phone to mark punch-in or punch-out. You can continue viewing attendance history from desktop.' }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||||
@@ -104,6 +111,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if employee and is_mobile_attendance_device %}
|
||||||
<div id="location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" aria-hidden="true">
|
<div id="location-consent-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" aria-hidden="true">
|
||||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
@@ -249,4 +257,5 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+81
-15
@@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -195,6 +195,80 @@ def _client_ip(request: Request) -> str | None:
|
|||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
|
_ATTENDANCE_MOBILE_ONLY_MESSAGE = (
|
||||||
|
"Attendance punch-in and punch-out are allowed only from a mobile browser with location/GPS enabled. "
|
||||||
|
"Please open the ERP on your mobile phone to mark attendance. Desktop access is view-only for attendance history."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mobile_attendance_device(request: Request) -> bool:
|
||||||
|
"""Server-side guard for staff self-attendance punch actions.
|
||||||
|
|
||||||
|
This intentionally does not replace GPS/geofence validation. It only blocks
|
||||||
|
desktop/laptop browsers from submitting punch-in or punch-out requests.
|
||||||
|
The existing attendance service still performs employee status, geo/IP,
|
||||||
|
timing, duplicate punch and approval checks.
|
||||||
|
"""
|
||||||
|
user_agent = (request.headers.get("user-agent") or "").strip().lower()
|
||||||
|
if not user_agent:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mobile_indicators = (
|
||||||
|
"mobi",
|
||||||
|
"android",
|
||||||
|
"iphone",
|
||||||
|
"ipod",
|
||||||
|
"ipad",
|
||||||
|
"windows phone",
|
||||||
|
"blackberry",
|
||||||
|
"bb10",
|
||||||
|
"opera mini",
|
||||||
|
"opera mobi",
|
||||||
|
"mobile safari",
|
||||||
|
)
|
||||||
|
desktop_indicators = (
|
||||||
|
"windows nt",
|
||||||
|
"macintosh",
|
||||||
|
"x11",
|
||||||
|
"cros",
|
||||||
|
"linux x86_64",
|
||||||
|
)
|
||||||
|
|
||||||
|
has_mobile_signal = any(token in user_agent for token in mobile_indicators)
|
||||||
|
has_desktop_signal = any(token in user_agent for token in desktop_indicators)
|
||||||
|
|
||||||
|
if has_mobile_signal:
|
||||||
|
return True
|
||||||
|
if has_desktop_signal:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _attendance_mobile_context(request: Request) -> dict:
|
||||||
|
is_mobile = _is_mobile_attendance_device(request)
|
||||||
|
return {
|
||||||
|
"is_mobile_attendance_device": is_mobile,
|
||||||
|
"attendance_punch_blocked_reason": None if is_mobile else _ATTENDANCE_MOBILE_ONLY_MESSAGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_self_attendance_page(request: Request, db, current_user, *, errors: list[str] | None = None):
|
||||||
|
employee = get_employee_for_user(db, current_user)
|
||||||
|
rows = list_own_attendance(db, current_user)
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"modules/employees/templates/employees/self_attendance.html",
|
||||||
|
db,
|
||||||
|
current_user,
|
||||||
|
title="My Attendance",
|
||||||
|
employee=employee,
|
||||||
|
rows=rows,
|
||||||
|
today_attendance=get_today_attendance_for_user(db, current_user),
|
||||||
|
attendance_rows=rows[:7],
|
||||||
|
errors=errors or [],
|
||||||
|
**_attendance_mobile_context(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _active_financial_year(request: Request) -> str | None:
|
def _active_financial_year(request: Request) -> str | None:
|
||||||
@@ -2527,6 +2601,7 @@ def _employee_portal_context(request: Request, db, current_user, *, active_tab:
|
|||||||
"payslips": list_own_payslips(db, current_user)[:5],
|
"payslips": list_own_payslips(db, current_user)[:5],
|
||||||
"own_documents": list_own_employee_documents(db, current_user)[:8],
|
"own_documents": list_own_employee_documents(db, current_user)[:8],
|
||||||
"work_payload": work_payload,
|
"work_payload": work_payload,
|
||||||
|
**_attendance_mobile_context(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2586,20 +2661,7 @@ def employee_self_attendance(request: Request):
|
|||||||
require_permission(db, current_user, "employees.attendance.view_self")
|
require_permission(db, current_user, "employees.attendance.view_self")
|
||||||
except Exception:
|
except Exception:
|
||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
employee = get_employee_for_user(db, current_user)
|
return _render_self_attendance_page(request, db, current_user, errors=[])
|
||||||
rows = list_own_attendance(db, current_user)
|
|
||||||
return _render(
|
|
||||||
request,
|
|
||||||
"modules/employees/templates/employees/self_attendance.html",
|
|
||||||
db,
|
|
||||||
current_user,
|
|
||||||
title="My Attendance",
|
|
||||||
employee=employee,
|
|
||||||
rows=rows,
|
|
||||||
today_attendance=get_today_attendance_for_user(db, current_user),
|
|
||||||
attendance_rows=list_own_attendance(db, current_user)[:7],
|
|
||||||
errors=[],
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -2626,6 +2688,8 @@ def employee_punch_in_submit(
|
|||||||
require_permission(db, current_user, "employees.attendance.punch")
|
require_permission(db, current_user, "employees.attendance.punch")
|
||||||
except Exception:
|
except Exception:
|
||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
|
if not _is_mobile_attendance_device(request):
|
||||||
|
return _render_self_attendance_page(request, db, current_user, errors=[_ATTENDANCE_MOBILE_ONLY_MESSAGE])
|
||||||
punch_in_attendance(
|
punch_in_attendance(
|
||||||
db,
|
db,
|
||||||
current_user,
|
current_user,
|
||||||
@@ -2662,6 +2726,8 @@ def employee_punch_out_submit(
|
|||||||
require_permission(db, current_user, "employees.attendance.punch")
|
require_permission(db, current_user, "employees.attendance.punch")
|
||||||
except Exception:
|
except Exception:
|
||||||
return _redirect_denied()
|
return _redirect_denied()
|
||||||
|
if not _is_mobile_attendance_device(request):
|
||||||
|
return _render_self_attendance_page(request, db, current_user, errors=[_ATTENDANCE_MOBILE_ONLY_MESSAGE])
|
||||||
punch_out_attendance(
|
punch_out_attendance(
|
||||||
db,
|
db,
|
||||||
current_user,
|
current_user,
|
||||||
|
|||||||
Reference in New Issue
Block a user