From e26a7cd9e846b80eeb9284643a0de37924a7f549 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 30 May 2026 18:14:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fclock):=20NFC=20photo=20capture?= =?UTF-8?q?=20=E2=80=94=2010s=20auto-capture,=20vertical=20oval,=20cache-b?= =?UTF-8?q?usted=20avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile photo DID save (verified: image_1920 attachment persists); the "doesn't update" was a browser-cache miss. Add ?unique= to the result-card avatar URL so a freshly-captured photo shows on clock in/out. - Capture now starts a 10-second countdown (time to get into frame) then auto-snaps; the button toggles to Cancel while counting. - Face guide is now a VERTICAL oval (aspect-ratio 3/4) over a portrait stage — it was rendering horizontal. Faces are taller than wide. Deployed live to entech (LXC 111) as 19.0.3.11.3; frontend bundle verified to compile clean and contain the new rules. Co-Authored-By: Claude Opus 4.8 --- fusion_clock/__manifest__.py | 2 +- fusion_clock/controllers/clock_nfc_kiosk.py | 9 ++++- .../static/src/js/fusion_clock_nfc_kiosk.js | 40 ++++++++++++++++++- fusion_clock/static/src/scss/nfc_kiosk.scss | 33 ++++++++++++--- 4 files changed, 73 insertions(+), 11 deletions(-) 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;