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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
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 › 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 — 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
|
||||
“Activate This Device.” 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>
|
||||
Reference in New Issue
Block a user