Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries) - KioskPin added to ApplicationUser; TimeclockKioskToken added to Company - TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete, tablet kiosk with device-cookie auth, PIN management via Users edit page - Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out - Attendance report at /Reports/Attendance with weekly subtotal rows - Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment) - AllowCustomFormulas wired through PlatformSubscriptionController + subscription views - Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating) - Help article (Help/Timeclock.cshtml) and AI knowledge base updated - Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -142,9 +142,17 @@
|
||||
if (key === 'name') { cfValidateFieldNameInput(i, val); cfRenderVariablePills(); }
|
||||
};
|
||||
|
||||
const CF_SHOP_RATE_VARS = [
|
||||
{ name: 'standard_labor_rate', title: 'StandardLaborRate — your billing rate in $/hr' },
|
||||
{ name: 'additional_coat_labor_pct', title: 'AdditionalCoatLaborPercent (0–100)' },
|
||||
{ name: 'markup_pct', title: 'GeneralMarkupPercentage (0–100)' },
|
||||
];
|
||||
|
||||
function cfValidateFieldName(name) {
|
||||
if (!name) return 'Field variable name is required.';
|
||||
if (name === 'rate') return '"rate" is reserved — it is pre-populated from the template\'s Default Rate.';
|
||||
if (CF_SHOP_RATE_VARS.some(v => v.name === name))
|
||||
return `"${name}" is a built-in shop rate variable and cannot be used as a field name.`;
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) return 'Must start with a letter and contain only letters, digits, or underscores (no spaces).';
|
||||
return null;
|
||||
}
|
||||
@@ -211,11 +219,15 @@
|
||||
const container = document.getElementById('cfVariablePills');
|
||||
if (!container) return;
|
||||
const names = cfFields.map(f => f.name).filter(n => n && !cfValidateFieldName(n));
|
||||
const all = [...names, 'rate'];
|
||||
container.innerHTML = all.map(n =>
|
||||
const userPills = [...names, 'rate'].map(n =>
|
||||
`<span class="badge bg-secondary me-1 mb-1" style="cursor:pointer; font-size:.75rem;"
|
||||
title="Click to insert into formula" onclick="cfInsertVariable('${escHtml(n)}')">${escHtml(n)}</span>`
|
||||
).join('');
|
||||
const shopPills = CF_SHOP_RATE_VARS.map(v =>
|
||||
`<span class="badge bg-info text-dark me-1 mb-1" style="cursor:pointer; font-size:.75rem;"
|
||||
title="${escHtml(v.title)}" onclick="cfInsertVariable('${escHtml(v.name)}')">${escHtml(v.name)}</span>`
|
||||
).join('');
|
||||
container.innerHTML = userPills + (shopPills ? `<span class="text-muted small me-1">| shop:</span>${shopPills}` : '');
|
||||
}
|
||||
|
||||
window.cfInsertVariable = function (name) {
|
||||
@@ -566,10 +578,16 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0 small">
|
||||
<div class="alert alert-info mb-2 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<code>rate</code> is always available — you set it as the <strong>Default Rate</strong> on the template
|
||||
and staff can override it per-use. Don’t create a field called <code>rate</code>.
|
||||
</div>
|
||||
<div class="alert alert-secondary mb-0 small">
|
||||
<i class="bi bi-shop me-1"></i>
|
||||
Three <strong>shop rate variables</strong> are injected automatically from your Company Settings:
|
||||
<code>standard_labor_rate</code> ($/hr), <code>additional_coat_labor_pct</code> (0–100),
|
||||
<code>markup_pct</code> (0–100). Click their teal pills to insert them — no need to declare them as fields.
|
||||
</div>`
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* timeclock-kiosk.js — tablet kiosk PIN flow */
|
||||
(function () {
|
||||
var employeeScreen = document.getElementById('tc-employee-screen');
|
||||
var pinScreen = document.getElementById('tc-pin-screen');
|
||||
var confirmScreen = document.getElementById('tc-confirm-screen');
|
||||
|
||||
var selectedUserId = null;
|
||||
var pinBuffer = '';
|
||||
var countdownTimer = null;
|
||||
|
||||
// ── Bootstrap: load employees ──────────────────────────────────────────
|
||||
loadEmployees();
|
||||
setInterval(loadEmployees, 30000); // refresh tile states every 30 s
|
||||
|
||||
function loadEmployees() {
|
||||
fetch('/Timeclock/KioskEmployees')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (employees) { renderEmployees(employees); });
|
||||
}
|
||||
|
||||
function renderEmployees(employees) {
|
||||
var grid = document.getElementById('tc-employee-grid');
|
||||
if (!employees.length) {
|
||||
grid.innerHTML = '<div class="text-muted text-center py-4 col-12">No employees have kiosk PINs set.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = employees.map(function (emp) {
|
||||
var inClass = emp.isClockedIn ? ' clocked-in' : '';
|
||||
var badge = emp.isClockedIn ? '<span class="tc-status-badge">In</span>' : '';
|
||||
return '<button class="tc-tile' + inClass + '" data-user-id="' + escHtml(emp.userId) + '" data-name="' + escHtml(emp.displayName) + '">' +
|
||||
'<div class="tc-avatar">' + escHtml(emp.initials) + '</div>' +
|
||||
'<span class="tc-name">' + escHtml(emp.displayName) + '</span>' +
|
||||
badge +
|
||||
'</button>';
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.tc-tile').forEach(function (tile) {
|
||||
tile.addEventListener('click', function () {
|
||||
openPinScreen(tile.getAttribute('data-user-id'), tile.getAttribute('data-name'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── PIN screen ──────────────────────────────────────────────────────────
|
||||
function openPinScreen(userId, name) {
|
||||
selectedUserId = userId;
|
||||
pinBuffer = '';
|
||||
renderPinDisplay();
|
||||
|
||||
document.getElementById('tc-pin-name').textContent = name;
|
||||
document.getElementById('tc-pin-error').classList.add('d-none');
|
||||
|
||||
showScreen(pinScreen);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tc-keypad-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var key = btn.getAttribute('data-key');
|
||||
handleKey(key);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('tc-back-btn').addEventListener('click', function () {
|
||||
showScreen(employeeScreen);
|
||||
});
|
||||
|
||||
function handleKey(key) {
|
||||
if (key === '←') {
|
||||
pinBuffer = pinBuffer.slice(0, -1);
|
||||
renderPinDisplay();
|
||||
} else if (key === '✓') {
|
||||
if (pinBuffer.length === 4) submitPin();
|
||||
} else if (/^\d$/.test(key)) {
|
||||
if (pinBuffer.length < 4) {
|
||||
pinBuffer += key;
|
||||
renderPinDisplay();
|
||||
if (pinBuffer.length === 4) submitPin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPinDisplay() {
|
||||
var filled = pinBuffer.length;
|
||||
var dots = '';
|
||||
for (var i = 0; i < 4; i++) {
|
||||
dots += filled > i ? '●' : '○';
|
||||
if (i < 3) dots += ' ';
|
||||
}
|
||||
document.getElementById('tc-pin-display').innerHTML = dots;
|
||||
}
|
||||
|
||||
function submitPin() {
|
||||
document.getElementById('tc-pin-error').classList.add('d-none');
|
||||
|
||||
fetch('/Timeclock/KioskPunch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: selectedUserId, pin: pinBuffer })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Error'; });
|
||||
return r.json();
|
||||
})
|
||||
.then(function (result) {
|
||||
showConfirmation(result);
|
||||
})
|
||||
.catch(function (msg) {
|
||||
pinBuffer = '';
|
||||
renderPinDisplay();
|
||||
var err = document.getElementById('tc-pin-error');
|
||||
err.textContent = msg;
|
||||
err.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Confirmation screen ─────────────────────────────────────────────────
|
||||
function showConfirmation(result) {
|
||||
var isIn = result.action === 'clockIn';
|
||||
var icon = isIn ? '✅' : '👋';
|
||||
var title = isIn ? 'Clocked In' : 'Clocked Out';
|
||||
var timeStr = new Date(result.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')';
|
||||
|
||||
document.getElementById('tc-confirm-icon').innerHTML = icon;
|
||||
document.getElementById('tc-confirm-title').textContent = result.displayName + ' — ' + title;
|
||||
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' — ' + title;
|
||||
document.getElementById('tc-confirm-time').textContent = timeStr;
|
||||
document.getElementById('tc-confirm-today').textContent = todayLine;
|
||||
|
||||
showScreen(confirmScreen);
|
||||
startCountdown(4);
|
||||
}
|
||||
|
||||
function startCountdown(seconds) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
var remaining = seconds;
|
||||
var el = document.getElementById('tc-countdown');
|
||||
el.textContent = remaining;
|
||||
|
||||
countdownTimer = setInterval(function () {
|
||||
remaining--;
|
||||
el.textContent = remaining;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
loadEmployees();
|
||||
showScreen(employeeScreen);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────────────
|
||||
function showScreen(screen) {
|
||||
[employeeScreen, pinScreen, confirmScreen].forEach(function (s) {
|
||||
s.style.display = s === screen ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,192 @@
|
||||
/* timeclock.js — main-app timeclock dashboard */
|
||||
var Timeclock = (function () {
|
||||
var _cfg = {};
|
||||
|
||||
function init(cfg) {
|
||||
_cfg = cfg;
|
||||
bindClockButtons();
|
||||
bindManagerEvents();
|
||||
scheduleWhosInRefresh();
|
||||
}
|
||||
|
||||
/* ── Clock In / Out ────────────────────────────────────────────────────── */
|
||||
function bindClockButtons() {
|
||||
var btnIn = document.getElementById('btn-clock-in');
|
||||
var btnOut = document.getElementById('btn-clock-out');
|
||||
|
||||
if (btnIn) {
|
||||
btnIn.addEventListener('click', function () {
|
||||
btnIn.disabled = true;
|
||||
postJson('/Timeclock/ClockIn', {})
|
||||
.then(function (r) { location.reload(); })
|
||||
.catch(function (err) {
|
||||
showFeedback(err, 'danger');
|
||||
btnIn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnOut) {
|
||||
btnOut.addEventListener('click', function () {
|
||||
btnOut.disabled = true;
|
||||
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
|
||||
postJson('/Timeclock/ClockOut', { entryId: entryId })
|
||||
.then(function (r) { location.reload(); })
|
||||
.catch(function (err) {
|
||||
showFeedback(err, 'danger');
|
||||
btnOut.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Who's In refresh every 60 s ──────────────────────────────────────── */
|
||||
function scheduleWhosInRefresh() {
|
||||
setInterval(refreshWhosIn, 60000);
|
||||
}
|
||||
|
||||
function refreshWhosIn() {
|
||||
fetch('/Timeclock/ActiveNow')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var count = data.length;
|
||||
var badge = document.getElementById('whos-in-count');
|
||||
if (badge) badge.textContent = count;
|
||||
|
||||
var ts = document.getElementById('whos-in-updated');
|
||||
if (ts) ts.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Manager: edit / delete ────────────────────────────────────────────── */
|
||||
function bindManagerEvents() {
|
||||
if (!_cfg.isManager) return;
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.btn-edit-entry')) openEditModal(e.target.closest('.btn-edit-entry'));
|
||||
if (e.target.closest('.btn-delete-entry')) confirmDelete(e.target.closest('.btn-delete-entry'));
|
||||
});
|
||||
|
||||
var btnSaveEdit = document.getElementById('btn-save-edit');
|
||||
if (btnSaveEdit) btnSaveEdit.addEventListener('click', saveEdit);
|
||||
|
||||
var btnLoad = document.getElementById('btn-manager-load');
|
||||
if (btnLoad) btnLoad.addEventListener('click', loadManagerHistory);
|
||||
}
|
||||
|
||||
function openEditModal(btn) {
|
||||
document.getElementById('edit-entry-id').value = btn.getAttribute('data-id');
|
||||
document.getElementById('edit-clock-in').value = toDatetimeLocal(btn.getAttribute('data-clockin'));
|
||||
var out = btn.getAttribute('data-clockout');
|
||||
document.getElementById('edit-clock-out').value = out ? toDatetimeLocal(out) : '';
|
||||
document.getElementById('edit-notes').value = btn.getAttribute('data-notes');
|
||||
new bootstrap.Modal(document.getElementById('editEntryModal')).show();
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
var id = parseInt(document.getElementById('edit-entry-id').value);
|
||||
var inVal = document.getElementById('edit-clock-in').value;
|
||||
var outVal = document.getElementById('edit-clock-out').value;
|
||||
var notes = document.getElementById('edit-notes').value;
|
||||
|
||||
postJson('/Timeclock/Edit', {
|
||||
id: id,
|
||||
clockInTime: new Date(inVal).toISOString(),
|
||||
clockOutTime: outVal ? new Date(outVal).toISOString() : null,
|
||||
notes: notes
|
||||
}).then(function () {
|
||||
location.reload();
|
||||
}).catch(function (err) {
|
||||
alert('Error saving: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete(btn) {
|
||||
if (!confirm('Delete this clock entry? This cannot be undone.')) return;
|
||||
var id = parseInt(btn.getAttribute('data-id'));
|
||||
postJson('/Timeclock/Delete', id)
|
||||
.then(function () { location.reload(); })
|
||||
.catch(function (err) { alert('Error: ' + err); });
|
||||
}
|
||||
|
||||
function loadManagerHistory() {
|
||||
var from = document.getElementById('manager-from').value;
|
||||
var to = document.getElementById('manager-to').value;
|
||||
var url = '/Timeclock/History?from=' + from + '&to=' + to;
|
||||
|
||||
fetch(url)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
renderManagerHistory(data);
|
||||
});
|
||||
}
|
||||
|
||||
function renderManagerHistory(entries) {
|
||||
var container = document.getElementById('manager-history-container');
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<div class="text-muted text-center py-4">No entries in this range.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = entries.map(function (e) {
|
||||
var inTime = new Date(e.clockInTime).toLocaleString();
|
||||
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '—';
|
||||
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
||||
return '<tr>' +
|
||||
'<td>' + escHtml(e.userDisplayName) + '</td>' +
|
||||
'<td>' + inTime + '</td>' +
|
||||
'<td>' + outTime + '</td>' +
|
||||
'<td>' + hours + '</td>' +
|
||||
'<td>' + escHtml(e.notes || '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = '<table class="table table-hover table-sm mb-0">' +
|
||||
'<thead class="table-light"><tr>' +
|
||||
'<th>Employee</th><th>Clock In</th><th>Clock Out</th><th>Hours</th><th>Notes</th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────────────────────── */
|
||||
function postJson(url, data) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgery() },
|
||||
body: JSON.stringify(data)
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Request failed'; });
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function getAntiForgery() {
|
||||
var el = document.querySelector('input[name="__RequestVerificationToken"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function showFeedback(msg, type) {
|
||||
var el = document.getElementById('clock-feedback');
|
||||
if (!el) return;
|
||||
el.className = 'mt-3 alert alert-' + type + ' alert-permanent';
|
||||
el.textContent = msg;
|
||||
el.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function toDatetimeLocal(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
var d = new Date(isoStr);
|
||||
var pad = function (n) { return String(n).padStart(2, '0'); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
|
||||
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
})();
|
||||
Reference in New Issue
Block a user