Add timeclock break/lunch tracking, manual entries, and attendance period picker

- ClockEntryType enum (Work/Break/Lunch) on EmployeeClockEntry; default 0 = Work
  so all existing entries are unaffected
- Migration AddClockEntryType applied
- Break and Lunch buttons on clock status card (only when AllowMultiplePunchesPerDay
  is enabled); GoOnBreak closes current Work segment and opens Break/Lunch segment
- Return to Work button when on break/lunch; closes break segment, opens new Work
- Status badges on clock card and Who'\''s In grid: Working / On Break / At Lunch
- Break/Lunch hours excluded from all day totals, week totals, metrics, and CSV
- Manager: Manual Entry modal to create a time entry for any company employee
- Attendance report defaults to current ISO week; Week/Month mode toggle with
  auto-submitting dropdowns (last 12 weeks or months); period label shown inline
- Attendance CSV: Type column added; day/week totals blank on Break/Lunch rows;
  filename uses period label
- Week subtotal rows suppressed in single-week view (shown in month view only)
- Help article and AI knowledge base updated for all new features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 09:30:39 -04:00
parent 8ae61b6c78
commit 9dd36238bb
14 changed files with 11909 additions and 181 deletions
+99 -10
View File
@@ -9,16 +9,19 @@ var Timeclock = (function () {
scheduleWhosInRefresh();
}
/* ── Clock In / Out ────────────────────────────────────────────────────── */
/* ── Clock In / Out / Break / Lunch / Return ───────────────────────────── */
function bindClockButtons() {
var btnIn = document.getElementById('btn-clock-in');
var btnOut = document.getElementById('btn-clock-out');
var btnIn = document.getElementById('btn-clock-in');
var btnOut = document.getElementById('btn-clock-out');
var btnBreak = document.getElementById('btn-break');
var btnLunch = document.getElementById('btn-lunch');
var btnReturn = document.getElementById('btn-return-from-break');
if (btnIn) {
btnIn.addEventListener('click', function () {
btnIn.disabled = true;
postJson('/Timeclock/ClockIn', {})
.then(function (r) { location.reload(); })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnIn.disabled = false;
@@ -31,13 +34,40 @@ var Timeclock = (function () {
btnOut.disabled = true;
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
postJson('/Timeclock/ClockOut', { entryId: entryId })
.then(function (r) { location.reload(); })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnOut.disabled = false;
});
});
}
// Break button (type 1) and Lunch button (type 2) share the same handler
[btnBreak, btnLunch].forEach(function (btn) {
if (!btn) return;
btn.addEventListener('click', function () {
btn.disabled = true;
var breakType = parseInt(btn.getAttribute('data-break-type'));
postJson('/Timeclock/GoOnBreak', { breakType: breakType })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btn.disabled = false;
});
});
});
if (btnReturn) {
btnReturn.addEventListener('click', function () {
btnReturn.disabled = true;
postJson('/Timeclock/ReturnFromBreak', {})
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnReturn.disabled = false;
});
});
}
}
/* ── Who's In refresh every 60 s ──────────────────────────────────────── */
@@ -58,7 +88,7 @@ var Timeclock = (function () {
});
}
/* ── Manager: edit / delete ────────────────────────────────────────────── */
/* ── Manager: edit / delete / manual entry ────────────────────────────── */
function bindManagerEvents() {
if (!_cfg.isManager) return;
@@ -72,6 +102,21 @@ var Timeclock = (function () {
var btnLoad = document.getElementById('btn-manager-load');
if (btnLoad) btnLoad.addEventListener('click', loadManagerHistory);
var btnSaveManual = document.getElementById('btn-save-manual-entry');
if (btnSaveManual) btnSaveManual.addEventListener('click', saveManualEntry);
// Pre-fill clock-in to "now" each time the modal opens
var manualModal = document.getElementById('manualEntryModal');
if (manualModal) {
manualModal.addEventListener('show.bs.modal', function () {
document.getElementById('manual-clock-in').value = toDatetimeLocal(new Date().toISOString());
document.getElementById('manual-clock-out').value = '';
document.getElementById('manual-notes').value = '';
document.getElementById('manual-employee').value = '';
setManualFeedback('', '');
});
}
}
function openEditModal(btn) {
@@ -109,6 +154,44 @@ var Timeclock = (function () {
.catch(function (err) { alert('Error: ' + err); });
}
function saveManualEntry() {
var userId = document.getElementById('manual-employee').value;
var inVal = document.getElementById('manual-clock-in').value;
var outVal = document.getElementById('manual-clock-out').value;
var notes = document.getElementById('manual-notes').value;
if (!userId) { setManualFeedback('Please select an employee.', 'danger'); return; }
if (!inVal) { setManualFeedback('Clock-in time is required.', 'danger'); return; }
if (outVal && new Date(outVal) <= new Date(inVal)) {
setManualFeedback('Clock-out must be after clock-in.', 'danger');
return;
}
var btn = document.getElementById('btn-save-manual-entry');
btn.disabled = true;
postJson('/Timeclock/ManualEntry', {
userId: userId,
clockInTime: new Date(inVal).toISOString(),
clockOutTime: outVal ? new Date(outVal).toISOString() : null,
notes: notes || null
}).then(function () {
bootstrap.Modal.getInstance(document.getElementById('manualEntryModal')).hide();
location.reload();
}).catch(function (err) {
setManualFeedback(err || 'Failed to save entry.', 'danger');
btn.disabled = false;
});
}
function setManualFeedback(msg, type) {
var el = document.getElementById('manual-entry-feedback');
if (!el) return;
if (!msg) { el.className = 'd-none'; el.textContent = ''; return; }
el.className = 'alert alert-' + type + ' alert-permanent py-2';
el.textContent = msg;
}
function loadManagerHistory() {
var from = document.getElementById('manager-from').value;
var to = document.getElementById('manager-to').value;
@@ -128,12 +211,18 @@ var Timeclock = (function () {
return;
}
var TYPE_LABELS = ['<span class="badge bg-success">Work</span>',
'<span class="badge bg-warning text-dark">Break</span>',
'<span class="badge bg-info text-dark">Lunch</span>'];
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;';
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;';
var typeBadge = TYPE_LABELS[e.entryType] || TYPE_LABELS[0];
return '<tr>' +
'<td>' + escHtml(e.userDisplayName) + '</td>' +
'<td>' + typeBadge + '</td>' +
'<td>' + inTime + '</td>' +
'<td>' + outTime + '</td>' +
'<td>' + hours + '</td>' +
@@ -143,7 +232,7 @@ var Timeclock = (function () {
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>' +
'<th>Employee</th><th>Type</th><th>Clock In</th><th>Clock Out</th><th>Hours</th><th>Notes</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}