diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index c427e79e..c76fb9b0 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.11.2', + 'version': '19.0.3.11.3', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 2716e8fd..5f69cb4d 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -307,6 +307,11 @@ class FusionClockNfcKiosk(http.Controller): api = FusionClockAPI() is_checked_in = employee.attendance_state == 'checked_in' + # Cache-buster: /web/image is browser-cached, so without a unique token a + # freshly-saved profile photo never shows. write_date bumps on every + # write (incl. saving image_1920), so it refreshes exactly when needed. + avatar_unique = employee.write_date.strftime('%Y%m%d%H%M%S') if employee.write_date else '' + avatar_url = f'/web/image/hr.employee/{employee.id}/avatar_128?unique={avatar_unique}' now = fields.Datetime.now() today = get_local_today(request.env, employee) day_plan = employee._get_fclk_day_plan(today) @@ -351,7 +356,7 @@ class FusionClockNfcKiosk(http.Controller): 'action': 'clock_in', 'employee_id': employee.id, 'employee_name': employee.name, - 'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128', + 'employee_avatar_url': avatar_url, 'message': f'{employee.name} clocked in at {location.name}', 'net_hours_today': 0.0, 'needs_photo': not employee.image_1920, @@ -377,7 +382,7 @@ class FusionClockNfcKiosk(http.Controller): 'action': 'clock_out', 'employee_id': employee.id, 'employee_name': employee.name, - 'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128', + 'employee_avatar_url': avatar_url, 'message': f'{employee.name} clocked out', 'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2), 'needs_photo': not employee.image_1920, diff --git a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js index 8c2a2e5d..f016467b 100644 --- a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js +++ b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js @@ -1039,6 +1039,7 @@

Take ${escapeHtml(employeeName)}'s photo

+
Center the face in the oval
@@ -1055,11 +1056,46 @@ v.srcObject = cameraStream; stage.insertBefore(v, stage.firstChild); v.play().catch(() => {}); - document.getElementById("photo_capture").addEventListener("click", () => { + + const captureBtn = document.getElementById("photo_capture"); + const skipBtn = document.getElementById("photo_skip"); + const cdEl = document.getElementById("photo_countdown"); + const hintEl = stage.querySelector(".nfc-photo-hint"); + let cdTimer = null; + const doCapture = () => { captured = _captureProfileSquare(v); if (captured) renderPreview(); else finish(); + }; + // Click Capture → 10s countdown (time to get into frame) → auto-snap. + // While counting, the button becomes Cancel (cdTimer also acts as the flag). + captureBtn.addEventListener("click", () => { + if (cdTimer) { + clearInterval(cdTimer); cdTimer = null; + cdEl.classList.remove("is-active"); cdEl.textContent = ""; + if (hintEl) hintEl.textContent = "Center the face in the oval"; + captureBtn.textContent = "Capture"; + return; + } + let n = 10; + captureBtn.textContent = "Cancel"; + if (hintEl) hintEl.textContent = "Get ready…"; + cdEl.textContent = n; + cdEl.classList.add("is-active"); + cdTimer = setInterval(() => { + n -= 1; + if (n <= 0) { + clearInterval(cdTimer); cdTimer = null; + cdEl.classList.remove("is-active"); cdEl.textContent = ""; + doCapture(); + return; + } + cdEl.textContent = n; + }, 1000); + }); + skipBtn.addEventListener("click", () => { + if (cdTimer) { clearInterval(cdTimer); cdTimer = null; } + finish(); }); - document.getElementById("photo_skip").addEventListener("click", finish); } renderLive(); } diff --git a/fusion_clock/static/src/scss/nfc_kiosk.scss b/fusion_clock/static/src/scss/nfc_kiosk.scss index 8e1fe2fd..487fc2c8 100644 --- a/fusion_clock/static/src/scss/nfc_kiosk.scss +++ b/fusion_clock/static/src/scss/nfc_kiosk.scss @@ -683,9 +683,10 @@ html:has(#nfc_kiosk_root) { } .nfc-photo-stage { position: relative; - width: 100%; - aspect-ratio: 3 / 4; - max-height: 56vh; + aspect-ratio: 3 / 4; // portrait — width follows the (height-driven) box + height: 56vh; + max-height: 440px; + max-width: 100%; margin: 0 auto; border-radius: 1rem; overflow: hidden; @@ -703,16 +704,36 @@ html:has(#nfc_kiosk_root) { .nfc-photo-video { transform: scaleX(-1); } // mirror for a natural selfie view .nfc-photo-guide { position: absolute; - top: 48%; + top: 47%; left: 50%; - width: 60%; - height: 66%; + width: 54%; + aspect-ratio: 3 / 4; // VERTICAL oval — a face is taller than it is wide transform: translate(-50%, -50%); border: 3px dashed rgba(255, 255, 255, 0.92); border-radius: 50%; box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); // dim everything outside the oval pointer-events: none; } +.nfc-photo-countdown { + position: absolute; + top: 47%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 6rem; + font-weight: 200; + line-height: 1; + color: #fff; + text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85); + pointer-events: none; + opacity: 0; + + &.is-active { opacity: 1; animation: nfc-cd-pop 1s ease-out infinite; } +} +@keyframes nfc-cd-pop { + 0% { transform: translate(-50%, -50%) scale(1.25); opacity: 0.35; } + 40% { opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.7; } +} .nfc-photo-hint { position: absolute; left: 0;