diff --git a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
index f8df2c1..f172fd3 100644
--- a/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
+++ b/src/PowderCoating.Application/DTOs/Company/CompanySettingsDtos.cs
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
/// True when the company has an accepted agreement for the current SmsTermsVersion.
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; }
+ }
+
+ /// DTO for updating company-level timeclock settings from the Settings tab.
+ 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; }
}
///
diff --git a/src/PowderCoating.Core/Entities/Company.cs b/src/PowderCoating.Core/Entities/Company.cs
index 4ee522b..9a533cb 100644
--- a/src/PowderCoating.Core/Entities/Company.cs
+++ b/src/PowderCoating.Core/Entities/Company.cs
@@ -133,11 +133,14 @@ public class Company : BaseEntity
///
public string? KioskActivationToken { get; set; }
- ///
- /// Device activation token for the Timeclock kiosk tablet.
- /// Null = timeclock kiosk not activated on any device.
- ///
- public string? TimeclockKioskToken { get; set; }
+ /// Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.
+ public bool TimeclockEnabled { get; set; } = true;
+
+ /// 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.
+ public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
+
+ /// If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.
+ public int? TimeclockAutoClockOutHours { get; set; }
// Navigation Properties
public virtual ICollection Users { get; set; } = new List();
diff --git a/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs b/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs
new file mode 100644
index 0000000..0d3c630
--- /dev/null
+++ b/src/PowderCoating.Core/Entities/TimeclockKioskDevice.cs
@@ -0,0 +1,22 @@
+namespace PowderCoating.Core.Entities;
+
+///
+/// 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 is stored in a
+/// device-specific cookie and validated on every kiosk request.
+///
+public class TimeclockKioskDevice : BaseEntity
+{
+ /// Human-readable label for this device (e.g. "Front Entrance Tablet").
+ public string? DeviceName { get; set; }
+
+ /// Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.
+ public string Token { get; set; } = string.Empty;
+
+ /// UTC timestamp when a manager activated this device.
+ public DateTime ActivatedAt { get; set; }
+
+ /// UTC timestamp of the most recent kiosk request from this device; null if never used after activation.
+ public DateTime? LastSeenAt { get; set; }
+}
diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
index 86e92b7..37687db 100644
--- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
+++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
@@ -160,6 +160,7 @@ IRepository ReworkRecords { get; }
// Employee Timeclock
IRepository EmployeeClockEntries { get; }
+ IRepository TimeclockKioskDevices { get; }
Task SaveChangesAsync();
Task CompleteAsync(); // Alias for SaveChangesAsync
diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
index a6294f0..d30ce26 100644
--- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
+++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
@@ -382,6 +382,9 @@ public class ApplicationDbContext : IdentityDbContext, IDataPro
/// Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).
public DbSet EmployeeClockEntries { get; set; }
+ /// One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.
+ public DbSet TimeclockKioskDevices { get; set; }
+
///
/// 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().HasQueryFilter(e =>
modelBuilder.Entity()
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
+ // Timeclock kiosk devices — one row per activated tablet per company
+ modelBuilder.Entity().HasQueryFilter(d =>
+ !d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
+ modelBuilder.Entity()
+ .HasIndex(d => d.Token).IsUnique();
+ modelBuilder.Entity()
+ .HasIndex(d => d.CompanyId);
+
// Account self-referencing hierarchy
modelBuilder.Entity()
.HasOne(a => a.ParentAccount)
diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
index 6e98c0f..345b534 100644
--- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
@@ -125,6 +125,7 @@ public class UnitOfWork : IUnitOfWork
// Employee Timeclock
private IRepository? _employeeClockEntries;
+ private IRepository? _timeclockKioskDevices;
// Custom Formula Templates
private IRepository? _customItemTemplates;
@@ -467,6 +468,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository EmployeeClockEntries =>
_employeeClockEntries ??= new Repository(_context);
+ /// Repository for activated tablet records; one row per device. Delete a row to revoke that device's access.
+ public IRepository TimeclockKioskDevices =>
+ _timeclockKioskDevices ??= new Repository(_context);
+
/// Repository for per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.
public IRepository CustomItemTemplates =>
_customItemTemplates ??= new Repository(_context);
diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
index 15c3253..6104f35 100644
--- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
+++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
@@ -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
+ ///
+ /// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
+ ///
+ [HttpPost]
+ public async Task 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." });
+ }
+ }
+
///
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
diff --git a/src/PowderCoating.Web/Controllers/TimeclockController.cs b/src/PowderCoating.Web/Controllers/TimeclockController.cs
index ee34c6b..a0aee11 100644
--- a/src/PowderCoating.Web/Controllers/TimeclockController.cs
+++ b/src/PowderCoating.Web/Controllers/TimeclockController.cs
@@ -131,24 +131,59 @@ public class TimeclockController : Controller
return Json(result);
}
- /// Clocks the current user in. Returns an error if already clocked in.
+ ///
+ /// 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.
+ ///
[HttpPost]
public async Task 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
// =========================================================================
///
- /// 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.
///
[HttpGet("Timeclock/Kiosk/Activate")]
public async Task 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();
}
- /// Deactivates the timeclock kiosk — clears the token and removes the cookie.
- [HttpPost("Timeclock/Kiosk/Deactivate")]
- public async Task KioskDeactivate()
+ ///
+ /// Creates a new row and writes the device cookie for
+ /// this tablet. Multiple devices per company are supported — each tablet gets its own token.
+ ///
+ [HttpPost("Timeclock/Kiosk/Activate")]
+ [ValidateAntiForgeryToken]
+ public async Task KioskActivatePost(string? deviceName)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
- var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
- if (company != null)
- {
- company.TimeclockKioskToken = null;
- await _unitOfWork.CompleteAsync();
- }
+ var token = Guid.NewGuid().ToString("N");
+
+ var device = new TimeclockKioskDevice
+ {
+ 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));
+ }
+
+ /// Deactivates a specific kiosk device by ID. Deletes its row so the cookie is invalidated on next use.
+ [HttpPost]
+ public async Task 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();
- DeleteTimeclockKioskCookie();
return Json(new { success = true });
}
+ /// Returns the list of active kiosk devices for this company. Used by the Settings tab.
+ [HttpGet]
+ public async Task 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
// =========================================================================
///
/// 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 rows (supports multiple tablets per company).
///
[AllowAnonymous]
[HttpGet("Timeclock/Kiosk")]
public async Task 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 › 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
// =========================================================================
+ ///
+ /// Validates the kiosk device cookie against the 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.
+ ///
+ 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);
+ }
+
+ /// Updates without blocking the response.
+ 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;
diff --git a/src/PowderCoating.Web/Middleware/SubscriptionMiddleware.cs b/src/PowderCoating.Web/Middleware/SubscriptionMiddleware.cs
index 00fbe1c..e06ce21 100644
--- a/src/PowderCoating.Web/Middleware/SubscriptionMiddleware.cs
+++ b/src/PowderCoating.Web/Middleware/SubscriptionMiddleware.cs
@@ -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)
{
diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
index 5cf746d..03fd5ab 100644
--- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
+++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml
@@ -106,6 +106,11 @@
Kiosk
+
+
+
@if (ViewBag.AllowCustomFormulas == true)
{
@@ -2062,6 +2067,108 @@
+
+
+
+
Timeclock Settings
+
+
+ @{
+ var kioskDevices = ViewBag.TimeclockKioskDevices as List ?? new();
+ }
+
+
+
+
+
+
+
When disabled, the Timeclock link is hidden from the navigation and the Attendance report is not accessible.
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+ hours
+
+
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.
+
+
+
+
+
+
+
+
Kiosk Tablets
+
+ Activate the timeclock kiosk on any shop-floor tablet. Employees tap their name and enter their PIN to clock in or out — no login required.
+ Multiple tablets are supported; each device is listed below.
+
+
+
+
+ To activate a new kiosk: on the tablet's browser, navigate to
+ /Timeclock/Kiosk/Activate, optionally enter a device name, and click
+ “Activate This Device.” The tablet will then show the employee clock-in grid at
+ /Timeclock/Kiosk.
+