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:
2026-05-26 19:53:13 -04:00
parent f625be01a3
commit 6c2fe6e1c4
40 changed files with 24125 additions and 16 deletions
@@ -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 (0100)' },
{ name: 'markup_pct', title: 'GeneralMarkupPercentage (0100)' },
];
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 &mdash; you set it as the <strong>Default Rate</strong> on the template
and staff can override it per-use. Don&rsquo;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&ndash;100),
<code>markup_pct</code> (0&ndash;100). Click their teal pills to insert them &mdash; 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 === '&larr;') {
pinBuffer = pinBuffer.slice(0, -1);
renderPinDisplay();
} else if (key === '&check;') {
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 ? '&#9679;' : '&#9675;';
if (i < 3) dots += '&nbsp;';
}
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 ? '&#x2705;' : '&#x1F44B;';
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 + ' &mdash; ' + title;
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
@@ -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() : '&mdash;';
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '&mdash;';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init: init };
})();