Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export

- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries)
- KioskPin added to ApplicationUser; TimeclockKioskToken added to Company
- TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete,
  tablet kiosk with device-cookie auth, PIN management via Users edit page
- Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out
- Attendance report at /Reports/Attendance with weekly subtotal rows
- Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment)
- AllowCustomFormulas wired through PlatformSubscriptionController + subscription views
- Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating)
- Help article (Help/Timeclock.cshtml) and AI knowledge base updated
- Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:53:13 -04:00
parent f625be01a3
commit 6c2fe6e1c4
40 changed files with 24125 additions and 16 deletions
@@ -0,0 +1,527 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Timeclock;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles employee facility-level timeclock: clock in/out, manager edits, and the
/// tablet kiosk page with PIN-based authentication.
/// Two authentication modes:
/// - Main app actions: standard [Authorize] cookie auth (normal nav users)
/// - Kiosk actions: device-cookie auth (shared tablet, no user login required)
/// </summary>
[Authorize]
public class TimeclockController : Controller
{
private const string KioskCookieName = "TimeclockKioskDevice";
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly ILogger<TimeclockController> _logger;
/// <summary>Initialises dependencies for the timeclock controller.</summary>
public TimeclockController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
IPasswordHasher<ApplicationUser> passwordHasher,
ILogger<TimeclockController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_passwordHasher = passwordHasher;
_logger = logger;
}
// =========================================================================
// MAIN APP — dashboard
// =========================================================================
/// <summary>
/// Timeclock dashboard: current user's punch button, "who's in" grid, and personal history.
/// </summary>
public async Task<IActionResult> Index()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
// Current user's open entry (null = clocked out)
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == currentUser.Id && e.ClockOutTime == null && e.CompanyId == companyId);
// All open entries for the "Who's In" table
var activeEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId,
false,
e => e.User);
// Current user's last 14 days of entries
var since = DateTime.UtcNow.Date.AddDays(-14);
var myHistory = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == currentUser.Id && e.ClockInTime >= since && e.CompanyId == companyId);
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
ViewBag.CurrentUser = currentUser;
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
ViewBag.IsManager = isManager;
ViewBag.NowUtc = DateTime.UtcNow;
return View();
}
// =========================================================================
// MAIN APP — clock in / out / status
// =========================================================================
/// <summary>Returns the current user's open clock entry, or null if clocked out.</summary>
[HttpGet]
public async Task<IActionResult> MyStatus()
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null) return Json(new { isClockedIn = false });
var elapsed = (decimal)(DateTime.UtcNow - openEntry.ClockInTime).TotalHours;
return Json(new
{
isClockedIn = true,
entryId = openEntry.Id,
clockInTime = openEntry.ClockInTime,
elapsedHours = Math.Round(elapsed, 2)
});
}
/// <summary>Returns all employees with an open clock entry (for the "Who's In" widget).</summary>
[HttpGet]
public async Task<IActionResult> ActiveNow()
{
var companyId = GetCurrentCompanyId();
var active = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId,
false,
e => e.User);
var result = active.Select(e => new
{
userId = e.UserId,
displayName = e.User?.FullName ?? "Unknown",
clockInTime = e.ClockInTime,
entryId = e.Id
});
return Json(result);
}
/// <summary>Clocks the current user in. Returns an error if already clocked in.</summary>
[HttpPost]
public async Task<IActionResult> ClockIn([FromBody] ClockInRequest request)
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
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." });
var entry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = DateTime.UtcNow,
Notes = request.Notes?.Trim()
};
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} clocked in (entry {EntryId})", userId, entry.Id);
return Json(new { success = true, entryId = entry.Id, clockInTime = entry.ClockInTime });
}
/// <summary>Clocks the current user out, storing the elapsed hours on the entry.</summary>
[HttpPost]
public async Task<IActionResult> ClockOut([FromBody] ClockOutRequest request)
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.EntryId);
if (entry == null || entry.UserId != userId || entry.CompanyId != companyId)
return NotFound(new { message = "Clock entry not found." });
if (entry.ClockOutTime != null)
return BadRequest(new { message = "This entry is already clocked out." });
var now = DateTime.UtcNow;
entry.ClockOutTime = now;
entry.HoursWorked = Math.Round((decimal)(now - entry.ClockInTime).TotalHours, 2);
if (!string.IsNullOrWhiteSpace(request.Notes))
entry.Notes = request.Notes.Trim();
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} clocked out (entry {EntryId}, {Hours}h)", userId, entry.Id, entry.HoursWorked);
return Json(new { success = true, hoursWorked = entry.HoursWorked, clockOutTime = entry.ClockOutTime });
}
// =========================================================================
// MAIN APP — manager: history, edit, delete
// =========================================================================
/// <summary>Returns paginated clock entries. Managers can pass any userId; others only see their own.</summary>
[HttpGet]
public async Task<IActionResult> History(DateTime? from, DateTime? to, string? userId)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var filterUser = isManager ? userId : currentUser.Id;
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& (filterUser == null || e.UserId == filterUser),
false,
e => e.User);
var dtos = entries.OrderByDescending(e => e.ClockInTime)
.Select(e => MapEntry(e, e.User))
.ToList();
return Json(dtos);
}
/// <summary>Manager-only: edit clock-in/out times or notes on any entry in the company.</summary>
[HttpPost]
public async Task<IActionResult> Edit([FromBody] EditClockEntryRequest request)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.Id);
if (entry == null || entry.CompanyId != companyId)
return NotFound(new { message = "Entry not found." });
entry.ClockInTime = request.ClockInTime;
entry.ClockOutTime = request.ClockOutTime;
entry.Notes = request.Notes?.Trim();
if (request.ClockOutTime.HasValue)
entry.HoursWorked = Math.Round(
(decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2);
else
entry.HoursWorked = null;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Manager-only: soft-delete a clock entry.</summary>
[HttpPost]
public async Task<IActionResult> Delete([FromBody] int id)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(id);
if (entry == null || entry.CompanyId != companyId)
return NotFound(new { message = "Entry not found." });
await _unitOfWork.EmployeeClockEntries.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
// =========================================================================
// KIOSK — device activation
// =========================================================================
/// <summary>
/// Manager activates a tablet device by navigating to this URL.
/// Generates (or regenerates) the company's TimeclockKioskToken and writes the device cookie.
/// </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));
}
/// <summary>Deactivates the timeclock kiosk — clears the token and removes the cookie.</summary>
[HttpPost("Timeclock/Kiosk/Deactivate")]
public async Task<IActionResult> KioskDeactivate()
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company != null)
{
company.TimeclockKioskToken = null;
await _unitOfWork.CompleteAsync();
}
DeleteTimeclockKioskCookie();
return Json(new { success = true });
}
// =========================================================================
// 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.
/// </summary>
[AllowAnonymous]
[HttpGet("Timeclock/Kiosk")]
public async Task<IActionResult> Kiosk()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == 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.");
ViewBag.CompanyName = company.CompanyName;
ViewBag.CompanyId = company.Id;
return View();
}
/// <summary>
/// Returns the list of active employees who have a kiosk PIN set, along with their current clock-in status.
/// Called by the kiosk page on load and after each punch.
/// </summary>
[AllowAnonymous, HttpGet]
public async Task<IActionResult> KioskEmployees()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return Forbid();
var companyId = cookie.Value.companyId;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.KioskPin != null)
.ToListAsync();
// Get current open entries for this company
var openUserIds = (await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId))
.Select(e => e.UserId)
.ToHashSet();
var employees = users.Select(u => new KioskEmployeeDto
{
UserId = u.Id,
DisplayName = u.FullName,
Initials = BuildInitials(u),
IsClockedIn = openUserIds.Contains(u.Id)
}).OrderBy(e => e.DisplayName).ToList();
return Json(employees);
}
/// <summary>
/// Kiosk punch endpoint: validates PIN and clocks the employee in or out automatically.
/// Returns the action taken ("clockIn" or "clockOut") and the segment/daily totals.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> KioskPunch([FromBody] KioskPunchRequest request)
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return Forbid();
var companyId = cookie.Value.companyId;
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." });
// Verify PIN
var hashResult = _passwordHasher.VerifyHashedPassword(user, user.KioskPin, request.Pin);
if (hashResult == PasswordVerificationResult.Failed)
return BadRequest(new { message = "Incorrect PIN. Please try again." });
var now = DateTime.UtcNow;
// Check for open entry
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == user.Id && e.ClockOutTime == null && e.CompanyId == companyId);
string action;
decimal segmentHours = 0;
if (openEntry != null)
{
// Clock out
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
await _unitOfWork.CompleteAsync();
action = "clockOut";
segmentHours = openEntry.HoursWorked.Value;
}
else
{
// Clock in
var entry = new EmployeeClockEntry
{
UserId = user.Id,
CompanyId = companyId,
ClockInTime = now
};
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
action = "clockIn";
}
// Compute today's running total (all completed segments + any still-open segment)
var todayStart = now.Date;
var todayEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= todayStart);
decimal dailyTotal = todayEntries.Sum(e =>
e.HoursWorked ?? (decimal)(now - e.ClockInTime).TotalHours);
dailyTotal = Math.Round(dailyTotal, 2);
return Json(new
{
action,
displayName = user.FullName,
timestamp = now,
segmentHours,
dailyTotal,
segmentCount = todayEntries.Count()
});
}
// =========================================================================
// PIN management (called from Users UI via AJAX)
// =========================================================================
/// <summary>Sets or clears a 4-digit kiosk PIN for the specified employee. Manager-only.</summary>
[HttpPost]
public async Task<IActionResult> SetKioskPin(string userId, string? pin)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var user = await _userManager.FindByIdAsync(userId);
if (user == null || user.CompanyId != companyId)
return NotFound(new { message = "User not found." });
if (string.IsNullOrWhiteSpace(pin))
{
user.KioskPin = null;
}
else
{
if (pin.Length != 4 || !pin.All(char.IsDigit))
return BadRequest(new { message = "PIN must be exactly 4 digits." });
user.KioskPin = _passwordHasher.HashPassword(user, pin);
}
await _userManager.UpdateAsync(user);
return Json(new { success = true });
}
// =========================================================================
// Helpers
// =========================================================================
private int GetCurrentCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out int id) ? id : 0;
}
private async Task<bool> IsManagerAsync()
{
var user = await _userManager.GetUserAsync(User);
return user?.CompanyRole is "CompanyAdmin" or "Manager";
}
private (int companyId, string token)? ReadTimeclockKioskCookie()
{
if (!Request.Cookies.TryGetValue(KioskCookieName, out var raw) || string.IsNullOrEmpty(raw))
return null;
var parts = raw.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
return null;
return (id, parts[1]);
}
private void WriteTimeclockKioskCookie(int companyId, string token)
{
Response.Cookies.Append(KioskCookieName, $"{companyId}:{token}", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(365)
});
}
private void DeleteTimeclockKioskCookie()
{
Response.Cookies.Delete(KioskCookieName);
}
private static EmployeeClockEntryDto MapEntry(EmployeeClockEntry e, ApplicationUser? user) =>
new()
{
Id = e.Id,
UserId = e.UserId,
UserDisplayName = user?.FullName ?? "Unknown",
ClockInTime = e.ClockInTime,
ClockOutTime = e.ClockOutTime,
HoursWorked = e.HoursWorked,
Notes = e.Notes
};
private static string BuildInitials(ApplicationUser u)
{
var first = string.IsNullOrEmpty(u.FirstName) ? "" : u.FirstName[0].ToString();
var last = string.IsNullOrEmpty(u.LastName) ? "" : u.LastName[0].ToString();
return (first + last).ToUpperInvariant();
}
}