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
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty;
// Timeclock settings
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
public int? TimeclockAutoClockOutHours { get; set; }
}
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
public class UpdateTimeclockSettingsDto
{
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
public int? TimeclockAutoClockOutHours { get; set; }
}
/// <summary>
+8 -5
View File
@@ -133,11 +133,14 @@ public class Company : BaseEntity
/// </summary>
public string? KioskActivationToken { get; set; }
/// <summary>
/// Device activation token for the Timeclock kiosk tablet.
/// Null = timeclock kiosk not activated on any device.
/// </summary>
public string? TimeclockKioskToken { get; set; }
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
public bool TimeclockEnabled { get; set; } = true;
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
public int? TimeclockAutoClockOutHours { get; set; }
// Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
@@ -0,0 +1,22 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents an activated shop-floor kiosk tablet for the timeclock.
/// One row per device; multiple rows per company are supported so shops can have
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
/// device-specific cookie and validated on every kiosk request.
/// </summary>
public class TimeclockKioskDevice : BaseEntity
{
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
public string? DeviceName { get; set; }
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
public string Token { get; set; } = string.Empty;
/// <summary>UTC timestamp when a manager activated this device.</summary>
public DateTime ActivatedAt { get; set; }
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
public DateTime? LastSeenAt { get; set; }
}
@@ -160,6 +160,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Employee Timeclock
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -382,6 +382,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
/// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -793,6 +796,14 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<EmployeeClockEntry>()
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
// Timeclock kiosk devices — one row per activated tablet per company
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.Token).IsUnique();
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.CompanyId);
// Account self-referencing hierarchy
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)
@@ -125,6 +125,7 @@ public class UnitOfWork : IUnitOfWork
// Employee Timeclock
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
@@ -467,6 +468,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
@@ -143,6 +143,13 @@ public class CompanySettingsController : Controller
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
// Timeclock settings
dto.TimeclockEnabled = company.TimeclockEnabled;
dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay;
dto.TimeclockAutoClockOutHours = company.TimeclockAutoClockOutHours;
var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value);
ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList();
// Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
@@ -741,6 +748,40 @@ public class CompanySettingsController : Controller
}
}
// POST: CompanySettings/UpdateTimeclockSettings
/// <summary>
/// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
return Json(new { success = false, message = "Company not found." });
company.TimeclockEnabled = dto.TimeclockEnabled;
company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay;
company.TimeclockAutoClockOutHours = dto.TimeclockAutoClockOutHours;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId);
return Json(new { success = true, message = "Timeclock settings saved." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating timeclock settings");
return Json(new { success = false, message = "An error occurred while saving timeclock settings." });
}
}
/// <summary>
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
@@ -131,24 +131,59 @@ public class TimeclockController : Controller
return Json(result);
}
/// <summary>Clocks the current user in. Returns an error if already clocked in.</summary>
/// <summary>
/// Clocks the current user in. Enforces three company-level timeclock settings:
/// (1) auto-closes stale open entries if AutoClockOutHours is set,
/// (2) blocks a second punch today if AllowMultiplePunchesPerDay is false,
/// (3) blocks immediately if already clocked in.
/// </summary>
[HttpPost]
public async Task<IActionResult> ClockIn([FromBody] ClockInRequest request)
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null) return Forbid();
var now = DateTime.UtcNow;
// Auto clock-out stale open entries if configured
if (company.TimeclockAutoClockOutHours is > 0)
{
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == userId && e.ClockOutTime == null
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
foreach (var staleEntry in stale)
{
staleEntry.ClockOutTime = staleEntry.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
staleEntry.HoursWorked = company.TimeclockAutoClockOutHours.Value;
staleEntry.Notes = (staleEntry.Notes + " [Auto clocked out]").Trim();
}
if (stale.Any()) await _unitOfWork.CompleteAsync();
}
// Still clocked in?
var alreadyOpen = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (alreadyOpen != null)
return BadRequest(new { message = "You are already clocked in. Please clock out first." });
// Block second punch today if setting disallows it
if (!company.TimeclockAllowMultiplePunchesPerDay)
{
var todayStart = now.Date;
var punchedToday = await _unitOfWork.EmployeeClockEntries.AnyAsync(
e => e.UserId == userId && e.CompanyId == companyId && e.ClockInTime >= todayStart);
if (punchedToday)
return BadRequest(new { message = "Multiple clock-ins per day are not allowed. You have already clocked in today." });
}
var entry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = DateTime.UtcNow,
ClockInTime = now,
Notes = request.Notes?.Trim()
};
@@ -264,64 +299,109 @@ public class TimeclockController : Controller
// =========================================================================
/// <summary>
/// Manager activates a tablet device by navigating to this URL.
/// Generates (or regenerates) the company's TimeclockKioskToken and writes the device cookie.
/// Shows the kiosk activation form where a manager can optionally name the device
/// before activating it. Navigate to this URL on each tablet to register it.
/// </summary>
[HttpGet("Timeclock/Kiosk/Activate")]
public async Task<IActionResult> KioskActivate()
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null) return NotFound();
company.TimeclockKioskToken = Guid.NewGuid().ToString("N");
await _unitOfWork.CompleteAsync();
WriteTimeclockKioskCookie(companyId, company.TimeclockKioskToken);
TempData["Success"] = "Timeclock kiosk activated on this device.";
return RedirectToAction(nameof(Kiosk));
return View();
}
/// <summary>Deactivates the timeclock kiosk — clears the token and removes the cookie.</summary>
[HttpPost("Timeclock/Kiosk/Deactivate")]
public async Task<IActionResult> KioskDeactivate()
/// <summary>
/// Creates a new <see cref="TimeclockKioskDevice"/> row and writes the device cookie for
/// this tablet. Multiple devices per company are supported — each tablet gets its own token.
/// </summary>
[HttpPost("Timeclock/Kiosk/Activate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> KioskActivatePost(string? deviceName)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company != null)
var token = Guid.NewGuid().ToString("N");
var device = new TimeclockKioskDevice
{
company.TimeclockKioskToken = null;
CompanyId = companyId,
Token = token,
DeviceName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim(),
ActivatedAt = DateTime.UtcNow
};
await _unitOfWork.TimeclockKioskDevices.AddAsync(device);
await _unitOfWork.CompleteAsync();
WriteTimeclockKioskCookie(companyId, token);
TempData["Success"] = "Kiosk activated on this device.";
return RedirectToAction(nameof(Kiosk));
}
/// <summary>Deactivates a specific kiosk device by ID. Deletes its row so the cookie is invalidated on next use.</summary>
[HttpPost]
public async Task<IActionResult> KioskDeactivate(int id)
{
if (!await IsManagerAsync()) return Json(new { success = false, message = "Forbidden." });
var companyId = GetCurrentCompanyId();
var device = await _unitOfWork.TimeclockKioskDevices.GetByIdAsync(id);
if (device == null || device.CompanyId != companyId)
return Json(new { success = false, message = "Device not found." });
await _unitOfWork.TimeclockKioskDevices.DeleteAsync(device);
await _unitOfWork.CompleteAsync();
// If the current browser holds this device's cookie, clear it too
var cookie = ReadTimeclockKioskCookie();
if (cookie?.token == device.Token)
DeleteTimeclockKioskCookie();
return Json(new { success = true });
}
/// <summary>Returns the list of active kiosk devices for this company. Used by the Settings tab.</summary>
[HttpGet]
public async Task<IActionResult> KioskDevices()
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var devices = await _unitOfWork.TimeclockKioskDevices.FindAsync(
d => d.CompanyId == companyId);
var result = devices.OrderBy(d => d.ActivatedAt).Select(d => new
{
id = d.Id,
deviceName = d.DeviceName ?? "Unnamed Device",
activatedAt = d.ActivatedAt,
lastSeenAt = d.LastSeenAt
});
return Json(result);
}
// =========================================================================
// KIOSK — tablet UI
// =========================================================================
/// <summary>
/// Tablet timeclock kiosk page. Requires device-cookie auth; no user login needed.
/// Shows employee tiles for all active employees who have a KioskPin set.
/// Validates the cookie token against <see cref="TimeclockKioskDevice"/> rows (supports multiple tablets per company).
/// </summary>
[AllowAnonymous]
[HttpGet("Timeclock/Kiosk")]
public async Task<IActionResult> Kiosk()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null)
var (device, company) = await ValidateKioskCookieAsync();
if (device == null || company == null)
return View("KioskError", "This device is not activated as a timeclock kiosk. Ask a manager to activate it at Timeclock &rsaquo; Activate Kiosk.");
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || company.TimeclockKioskToken != cookie.Value.token)
return View("KioskError", "Kiosk activation is invalid or has been revoked. Ask a manager to re-activate this device.");
if (!company.TimeclockEnabled)
return View("KioskError", "Timeclock is currently disabled for this company.");
await TouchDeviceAsync(device);
ViewBag.CompanyName = company.CompanyName;
ViewBag.CompanyId = company.Id;
@@ -338,6 +418,12 @@ public class TimeclockController : Controller
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return Forbid();
// Validate device (no need to touch LastSeenAt on every poll)
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
true);
if (device == null) return Forbid();
var companyId = cookie.Value.companyId;
var users = await _userManager.Users
@@ -373,6 +459,13 @@ public class TimeclockController : Controller
var companyId = cookie.Value.companyId;
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == companyId && d.Token == cookie.Value.token, true);
if (device == null) return Forbid();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || !company.TimeclockEnabled) return Forbid();
var user = await _userManager.FindByIdAsync(request.UserId);
if (user == null || user.CompanyId != companyId || !user.IsActive || user.KioskPin == null)
return BadRequest(new { message = "Employee not found or kiosk disabled." });
@@ -384,6 +477,22 @@ public class TimeclockController : Controller
var now = DateTime.UtcNow;
// Auto clock-out stale open entries (shared logic with main-app ClockIn)
if (company.TimeclockAutoClockOutHours is > 0)
{
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == user.Id && e.ClockOutTime == null
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
foreach (var s in stale)
{
s.ClockOutTime = s.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
s.HoursWorked = company.TimeclockAutoClockOutHours.Value;
s.Notes = (s.Notes + " [Auto clocked out]").Trim();
}
if (stale.Any()) await _unitOfWork.CompleteAsync();
}
// Check for open entry
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == user.Id && e.ClockOutTime == null && e.CompanyId == companyId);
@@ -402,6 +511,15 @@ public class TimeclockController : Controller
}
else
{
// Block second punch today if not allowed
if (!company.TimeclockAllowMultiplePunchesPerDay)
{
var todayPunched = await _unitOfWork.EmployeeClockEntries.AnyAsync(
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= now.Date);
if (todayPunched)
return BadRequest(new { message = "Multiple clock-ins per day are not allowed." });
}
// Clock in
var entry = new EmployeeClockEntry
{
@@ -468,6 +586,33 @@ public class TimeclockController : Controller
// Helpers
// =========================================================================
/// <summary>
/// Validates the kiosk device cookie against the <see cref="TimeclockKioskDevice"/> table.
/// Returns (null, null) if the cookie is absent or the token no longer exists.
/// Queries with ignoreQueryFilters because the kiosk runs without a logged-in user context.
/// </summary>
private async Task<(TimeclockKioskDevice? device, Company? company)> ValidateKioskCookieAsync()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return (null, null);
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
true);
if (device == null) return (null, null);
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
return (device, company);
}
/// <summary>Updates <see cref="TimeclockKioskDevice.LastSeenAt"/> without blocking the response.</summary>
private async Task TouchDeviceAsync(TimeclockKioskDevice device)
{
device.LastSeenAt = DateTime.UtcNow;
await _unitOfWork.TimeclockKioskDevices.UpdateAsync(device);
await _unitOfWork.CompleteAsync();
}
private int GetCurrentCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
@@ -97,6 +97,7 @@ public class SubscriptionMiddleware
context.Items["AllowAccounting"] = true;
context.Items["AllowSms"] = true;
context.Items["AllowCustomFormulas"] = true;
context.Items["TimeclockEnabled"] = true;
await _next(context);
return;
}
@@ -148,6 +149,7 @@ public class SubscriptionMiddleware
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
context.Items["AllowCustomFormulas"] = company.IsComped
|| (planConfig?.AllowCustomFormulas ?? false);
context.Items["TimeclockEnabled"] = company.TimeclockEnabled;
if (company.IsComped)
{
@@ -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>