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;