253 lines
13 KiB
HTML
253 lines
13 KiB
HTML
{% extends "ui/templates/base/layout.html" %}
|
|
{% block content %}
|
|
<div class="space-y-6">
|
|
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-slate-900">My Attendance</h2>
|
|
<p class="text-sm text-slate-500">Punch in/out and view your recent attendance records. Times are documented in your branch timezone.</p>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{% for error in errors or [] %}
|
|
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ error }}</div>
|
|
{% endfor %}
|
|
|
|
{% if not employee %}
|
|
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 shadow-soft">
|
|
Your login is not linked to an employee profile yet. Please request employee linkage first.
|
|
<div class="mt-3"><a href="/employee/register" class="font-semibold underline">Request employee profile</a></div>
|
|
</div>
|
|
{% else %}
|
|
<div class="grid gap-4 md:grid-cols-3">
|
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
|
<div class="text-xs font-semibold uppercase text-slate-500">Today</div>
|
|
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.attendance_date if today_attendance else 'Not marked' }}</div>
|
|
</div>
|
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
|
<div class="text-xs font-semibold uppercase text-slate-500">Punch In</div>
|
|
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.punch_in_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_in_local_at else (today_attendance.punch_in_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_in_utc else '-') }}</div>
|
|
</div>
|
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
|
<div class="text-xs font-semibold uppercase text-slate-500">Punch Out</div>
|
|
<div class="mt-1 text-lg font-semibold text-slate-900">{{ today_attendance.punch_out_local_at.strftime('%H:%M') if today_attendance and today_attendance.punch_out_local_at else (today_attendance.punch_out_utc.strftime('%H:%M UTC') if today_attendance and today_attendance.punch_out_utc else '-') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<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>
|
|
<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">
|
|
<form method="post" action="/employee/attendance/punch-in" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<input type="hidden" name="latitude">
|
|
<input type="hidden" name="longitude">
|
|
<input type="hidden" name="accuracy_meters">
|
|
<label class="block text-sm font-medium text-slate-700">Remarks / OD Reason</label>
|
|
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional; required in practice for client visit/OD">
|
|
<button class="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 method="post" action="/employee/attendance/punch-out" class="attendance-geo-form space-y-3 rounded-xl border border-slate-200 p-4">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<input type="hidden" name="latitude">
|
|
<input type="hidden" name="longitude">
|
|
<input type="hidden" name="accuracy_meters">
|
|
<label class="block text-sm font-medium text-slate-700">Remarks / OD Reason</label>
|
|
<input name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Optional remarks">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th class="px-4 py-3">Date</th>
|
|
<th class="px-4 py-3">Punch In</th>
|
|
<th class="px-4 py-3">Punch Out</th>
|
|
<th class="px-4 py-3">Duration</th>
|
|
<th class="px-4 py-3">Status</th>
|
|
<th class="px-4 py-3">Approval</th>
|
|
<th class="px-4 py-3">Timing Rule</th>
|
|
<th class="px-4 py-3">Geo/IP</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
{% for row in rows %}
|
|
<tr class="hover:bg-slate-50">
|
|
<td class="px-4 py-3 font-medium text-slate-900">{{ row.attendance_date }}</td>
|
|
<td class="px-4 py-3 text-slate-600">{{ row.punch_in_local_at.strftime('%H:%M') if row.punch_in_local_at else (row.punch_in_utc.strftime('%H:%M UTC') if row.punch_in_utc else '-') }}</td>
|
|
<td class="px-4 py-3 text-slate-600">{{ row.punch_out_local_at.strftime('%H:%M') if row.punch_out_local_at else (row.punch_out_utc.strftime('%H:%M UTC') if row.punch_out_utc else '-') }}</td>
|
|
<td class="px-4 py-3 text-slate-600">{{ (row.work_duration_minutes ~ ' min') if row.work_duration_minutes else '-' }}</td>
|
|
<td class="px-4 py-3">{{ row.status.replace('_',' ').title() }}</td>
|
|
<td class="px-4 py-3">{{ row.approval_status.replace('_',' ').title() }}</td>
|
|
<td class="px-4 py-3 text-xs text-slate-600">
|
|
<div>TZ: {{ row.branch_timezone or 'Asia/Kolkata' }}</div>
|
|
<div>Rule: {{ (row.attendance_rule_status or '-').replace('_',' ').title() }}</div>
|
|
<div>Late: {{ (row.late_by_minutes ~ ' min') if row.late_by_minutes else '-' }}</div>
|
|
</td>
|
|
<td class="px-4 py-3 text-xs text-slate-600">
|
|
<div>Geo: {{ (row.punch_in_geo_status or '-').replace('_',' ').title() }}</div>
|
|
<div>Distance: {{ (row.punch_in_distance_meters ~ ' m') if row.punch_in_distance_meters is not none else '-' }}</div>
|
|
<div>IP: {{ (row.punch_in_ip_status or '-').replace('_',' ').title() }}</div>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No attendance records found.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<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="flex items-start gap-3">
|
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">📍</div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-slate-900">Enable location for attendance</h3>
|
|
<p class="mt-2 text-sm text-slate-600">
|
|
To mark attendance, this app needs your current location to verify whether you are within your branch geofence.
|
|
Click Continue, then choose <span class="font-semibold text-slate-900">Allow</span> in the browser location popup.
|
|
</p>
|
|
<p class="mt-2 text-xs 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.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="mt-5 flex justify-end gap-3">
|
|
<button type="button" id="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="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>
|
|
|
|
<script>
|
|
(function () {
|
|
const statusBox = document.getElementById('geo-status');
|
|
const forms = document.querySelectorAll('.attendance-geo-form');
|
|
const modal = document.getElementById('location-consent-modal');
|
|
const modalContinue = document.getElementById('location-modal-continue');
|
|
const modalCancel = document.getElementById('location-modal-cancel');
|
|
let lastPosition = null;
|
|
let geoAttempted = false;
|
|
let activeForm = null;
|
|
|
|
function updateStatus(message, tone) {
|
|
if (!statusBox) return;
|
|
statusBox.textContent = message;
|
|
statusBox.className = 'mt-3 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(lat, lon, acc) {
|
|
forms.forEach(function (form) {
|
|
const latInput = form.querySelector('input[name="latitude"]');
|
|
const lonInput = form.querySelector('input[name="longitude"]');
|
|
const accInput = form.querySelector('input[name="accuracy_meters"]');
|
|
if (latInput) latInput.value = lat || '';
|
|
if (lonInput) lonInput.value = lon || '';
|
|
if (accInput) accInput.value = acc || '';
|
|
});
|
|
}
|
|
|
|
function openLocationModal(form) {
|
|
return new Promise(function (resolve) {
|
|
activeForm = form;
|
|
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() {
|
|
return new Promise(function (resolve) {
|
|
geoAttempted = true;
|
|
if (!navigator.geolocation) {
|
|
setHidden('', '', '');
|
|
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) {
|
|
const c = pos.coords || {};
|
|
lastPosition = c;
|
|
setHidden(c.latitude, c.longitude, c.accuracy);
|
|
updateStatus('Location captured. Accuracy: ' + Math.round(c.accuracy || 0) + ' meters.', 'ok');
|
|
resolve(true);
|
|
}, function (err) {
|
|
lastPosition = null;
|
|
setHidden('', '', '');
|
|
let reason = 'Location permission denied or unavailable.';
|
|
if (err && err.code === 1) reason = 'Location permission denied. Please allow location for this site in browser settings.';
|
|
if (err && err.code === 2) reason = 'Location unavailable. Please enable device GPS/location service and try again.';
|
|
if (err && err.code === 3) reason = 'Location capture timed out. Please try again near a window or with GPS enabled.';
|
|
updateStatus(reason + ' Attendance was not submitted. Please allow browser location/GPS and try again.', 'warn');
|
|
resolve(false);
|
|
}, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
|
|
});
|
|
}
|
|
|
|
updateStatus('Click Punch In/Punch Out. The app will ask you to enable location before submitting attendance.', 'info');
|
|
|
|
forms.forEach(function (form) {
|
|
form.addEventListener('submit', async function (event) {
|
|
const latInput = form.querySelector('input[name="latitude"]');
|
|
const lonInput = form.querySelector('input[name="longitude"]');
|
|
if (!latInput || !lonInput) return;
|
|
|
|
if (!latInput.value || !lonInput.value) {
|
|
event.preventDefault();
|
|
const proceed = await openLocationModal(form);
|
|
if (!proceed) return;
|
|
|
|
const captured = await captureLocation();
|
|
if (!captured) {
|
|
updateStatus('Attendance not submitted because location was not captured.', 'warn');
|
|
return;
|
|
}
|
|
form.submit();
|
|
}
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|