feat(fusion_clock): NFC photo capture — 10s auto-capture, vertical oval, cache-busted avatar
- Profile photo DID save (verified: image_1920 attachment persists); the "doesn't update" was a browser-cache miss. Add ?unique=<write_date> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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': """
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1039,6 +1039,7 @@
|
||||
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
|
||||
<div class="nfc-photo-stage" id="photo_stage">
|
||||
<div class="nfc-photo-guide"></div>
|
||||
<div class="nfc-photo-countdown" id="photo_countdown"></div>
|
||||
<div class="nfc-photo-hint">Center the face in the oval</div>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user