Add Timeclock settings tab in Company Settings with multi-kiosk support

Settings tab (Company Settings > Timeclock):
- Enable/disable timeclock toggle (hides nav link and attendance report when off)
- Allow multiple clock-ins per day toggle
- Auto clock-out after X hours (auto-closes forgotten open entries on next punch)
- Kiosk devices table: lists activated tablets with name, activated date, last seen;
  Deactivate button removes that device's access immediately

Multi-kiosk support (replaces single TimeclockKioskToken on Company):
- New TimeclockKioskDevice entity (one row per tablet, unique token, DeviceName, LastSeenAt)
- KioskActivate GET shows a form for optional device name before activating
- KioskDeactivate POST accepts device ID, deletes specific row (not all devices)
- Kiosk validation (Kiosk, KioskEmployees, KioskPunch) queries device table with
  ignoreQueryFilters since no user is logged in on kiosk requests
- LastSeenAt updated on each Kiosk page load

Enforcement:
- ClockIn and KioskPunch both auto-close stale entries if AutoClockOutHours is set
- ClockIn and KioskPunch both block second same-day punch if AllowMultiplePunches=false
- TimeclockEnabled=false hides nav link (SubscriptionMiddleware sets Items key) and
  returns Forbid on kiosk punch
- Migration: AddTimeclockSettings (adds 3 columns to Companies, new TimeclockKioskDevices table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 00:12:46 -04:00
parent e124fd5c8b
commit 97745f9a65
12 changed files with 479 additions and 38 deletions
@@ -106,6 +106,11 @@
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="timeclock-tab" data-bs-toggle="tab" data-bs-target="#timeclock" type="button" role="tab">
<i class="bi bi-clock-history"></i> Timeclock
</button>
</li>
@if (ViewBag.AllowCustomFormulas == true)
{
<li class="nav-item" role="presentation">
@@ -2062,6 +2067,108 @@
</div>
</div>
<!-- ── Timeclock ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="timeclock" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header fw-semibold"><i class="bi bi-clock-history me-2"></i>Timeclock Settings</div>
<div class="card-body">
@{
var kioskDevices = ViewBag.TimeclockKioskDevices as List<PowderCoating.Core.Entities.TimeclockKioskDevice> ?? new();
}
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="timeclockEnabled" @(Model.TimeclockEnabled ? "checked" : "") />
<label class="form-check-label fw-semibold" for="timeclockEnabled">Enable Timeclock</label>
</div>
<div class="form-text">When disabled, the Timeclock link is hidden from the navigation and the Attendance report is not accessible.</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="timeclockMultiplePunches" @(Model.TimeclockAllowMultiplePunchesPerDay ? "checked" : "") />
<label class="form-check-label fw-semibold" for="timeclockMultiplePunches">Allow multiple clock-ins per day</label>
</div>
<div class="form-text">When enabled, employees can clock in and out multiple times per day (e.g. for lunch breaks). When disabled, each employee is limited to one in/out pair per day.</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="timeclockAutoClockOut">Auto clock-out after</label>
<div class="input-group" style="max-width:220px;">
<input type="number" id="timeclockAutoClockOut" class="form-control" min="1" max="24"
value="@(Model.TimeclockAutoClockOutHours?.ToString() ?? "")"
placeholder="Disabled" />
<span class="input-group-text">hours</span>
</div>
<div class="form-text">If an employee forgets to clock out, the system will automatically clock them out after this many hours. Leave blank to disable. Maximum 24 hours.</div>
</div>
<button type="button" class="btn btn-primary" id="btn-save-timeclock">
<i class="bi bi-save me-1"></i>Save Timeclock Settings
</button>
<span id="timeclock-status" class="ms-2 small"></span>
<hr class="my-4" />
<h6 class="fw-semibold mb-1">Kiosk Tablets</h6>
<p class="text-muted small mb-3">
Activate the timeclock kiosk on any shop-floor tablet. Employees tap their name and enter their PIN to clock in or out &mdash; no login required.
Multiple tablets are supported; each device is listed below.
</p>
<div class="alert alert-info alert-permanent small mb-3">
<i class="bi bi-tablet me-2"></i>
<strong>To activate a new kiosk:</strong> on the tablet's browser, navigate to
<code>/Timeclock/Kiosk/Activate</code>, optionally enter a device name, and click
&ldquo;Activate This Device.&rdquo; The tablet will then show the employee clock-in grid at
<code>/Timeclock/Kiosk</code>.
</div>
@if (!kioskDevices.Any())
{
<p class="text-muted small fst-italic">No kiosk devices activated yet.</p>
}
else
{
<table class="table table-sm table-hover">
<thead class="table-light">
<tr>
<th>Device Name</th>
<th>Activated</th>
<th>Last Seen</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var dev in kioskDevices)
{
<tr data-device-id="@dev.Id">
<td class="fw-semibold">@(dev.DeviceName ?? "Unnamed Device")</td>
<td class="text-muted small">@dev.ActivatedAt.ToLocalTime().ToString("M/d/yy h:mm tt")</td>
<td class="text-muted small">
@if (dev.LastSeenAt.HasValue)
{ @dev.LastSeenAt.Value.ToLocalTime().ToString("M/d/yy h:mm tt") }
else
{ <span class="fst-italic">Never used</span> }
</td>
<td class="text-end">
<button class="btn btn-xs btn-outline-danger btn-deactivate-kiosk"
data-id="@dev.Id"
data-name="@(dev.DeviceName ?? "Unnamed Device")">
<i class="bi bi-x-circle me-1"></i>Deactivate
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
@if (ViewBag.AllowCustomFormulas == true)
{
@@ -3478,6 +3585,60 @@
if (btn) new bootstrap.Tab(btn).show();
}
</script>
<script>
// Timeclock settings
(function () {
var antiForgery = document.querySelector('input[name="__RequestVerificationToken"]');
var token = antiForgery ? antiForgery.value : '';
function setStatus(msg, ok) {
var el = document.getElementById('timeclock-status');
el.textContent = msg;
el.className = 'ms-2 small text-' + (ok ? 'success' : 'danger');
setTimeout(function () { el.textContent = ''; }, 4000);
}
document.getElementById('btn-save-timeclock').addEventListener('click', function () {
var hours = document.getElementById('timeclockAutoClockOut').value.trim();
fetch('/CompanySettings/UpdateTimeclockSettings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token },
body: JSON.stringify({
TimeclockEnabled: document.getElementById('timeclockEnabled').checked,
TimeclockAllowMultiplePunchesPerDay: document.getElementById('timeclockMultiplePunches').checked,
TimeclockAutoClockOutHours: hours !== '' ? parseInt(hours, 10) : null
})
}).then(function (r) { return r.json(); }).then(function (res) {
setStatus(res.message, res.success);
}).catch(function () { setStatus('Error saving settings.', false); });
});
document.querySelectorAll('.btn-deactivate-kiosk').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.dataset.id;
var name = btn.dataset.name;
if (!confirm('Deactivate "' + name + '"? The tablet will lose kiosk access immediately.')) return;
fetch('/Timeclock/KioskDeactivate/' + id, {
method: 'POST',
headers: { 'RequestVerificationToken': token }
}).then(function (r) { return r.json(); }).then(function (res) {
if (res.success) {
var row = document.querySelector('tr[data-device-id="' + id + '"]');
if (row) row.remove();
} else {
alert(res.message || 'Error deactivating device.');
}
});
});
});
// Auto-show tab if URL fragment matches
if (window.location.hash === '#timeclock' || new URLSearchParams(window.location.search).get('tab') === 'timeclock') {
var btn = document.querySelector('[data-bs-target="#timeclock"]');
if (btn) new bootstrap.Tab(btn).show();
}
})();
</script>
<script src="~/js/company-settings-custom-formulas.js" asp-append-version="true"></script>
<script>
// Load formula templates when the tab is first shown; auto-show walkthrough if no templates yet
@@ -1270,10 +1270,13 @@ var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports")
<span>Maintenance</span>
</a>
}
@if (Context.Items["TimeclockEnabled"] as bool? == true)
{
<a asp-controller="Timeclock" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-clock-history"></i>
<span>Timeclock</span>
</a>
}
@* ── Reports & Templates ──────────────────────────────────── *@
@if (hasReports || hasJobs)
@@ -0,0 +1,33 @@
@{
ViewData["Title"] = "Activate Kiosk Device";
Layout = "_KioskLayout";
}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:80vh;">
<div class="card shadow-lg" style="max-width:420px;width:100%;">
<div class="card-header text-center py-3">
<h4 class="mb-0"><i class="bi bi-tablet me-2"></i>Activate This Device</h4>
</div>
<div class="card-body p-4">
<p class="text-muted text-center mb-4">
This will register the current device as a Timeclock kiosk tablet.
Employees will be able to clock in and out without logging in.
</p>
<form method="post" asp-action="KioskActivatePost" asp-route-area="">
@Html.AntiForgeryToken()
<div class="mb-4">
<label class="form-label fw-semibold">Device Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" name="deviceName" class="form-control form-control-lg"
placeholder="e.g. Front Entrance Tablet" maxlength="100" />
<div class="form-text">Helps you identify this device on the Timeclock Settings page.</div>
</div>
<button type="submit" class="btn btn-success btn-lg w-100">
<i class="bi bi-check-circle me-2"></i>Activate This Device
</button>
</form>
</div>
<div class="card-footer text-center text-muted small py-3">
Only Managers and Company Admins can activate kiosk devices.
</div>
</div>
</div>