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.Enums; using PowderCoating.Core.Interfaces; namespace PowderCoating.Web.Controllers; /// /// 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) /// [Authorize] public class TimeclockController : Controller { private const string KioskCookieName = "TimeclockKioskDevice"; private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly IPasswordHasher _passwordHasher; private readonly ILogger _logger; /// Initialises dependencies for the timeclock controller. public TimeclockController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, IPasswordHasher passwordHasher, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _passwordHasher = passwordHasher; _logger = logger; } // ========================================================================= // MAIN APP — dashboard // ========================================================================= /// /// Timeclock dashboard: current user's punch button, "who's in" grid, and personal history. /// public async Task Index() { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Forbid(); var companyId = currentUser.CompanyId; var company = await _unitOfWork.Companies.GetByIdAsync(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"; // Load company employees for the manual-entry dropdown (managers only) List<(string Id, string Name)> companyUsers = new(); if (isManager) { companyUsers = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .Select(u => new { u.Id, u.FirstName, u.LastName }) .ToListAsync() .ContinueWith(t => t.Result .Select(u => (u.Id, $"{u.FirstName} {u.LastName}".Trim())) .ToList()); } 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; ViewBag.CompanyUsers = companyUsers; ViewBag.AllowMultiplePunches = company?.TimeclockAllowMultiplePunchesPerDay ?? true; return View(); } // ========================================================================= // MAIN APP — clock in / out / status // ========================================================================= /// Returns the current user's open clock entry, or null if clocked out. [HttpGet] public async Task 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) }); } /// Returns all employees with an open clock entry (for the "Who's In" widget). [HttpGet] public async Task 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, entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch }); return Json(result); } /// /// 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 = now, 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 }); } /// Clocks the current user out, storing the elapsed hours on the entry. [HttpPost] public async Task 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 // ========================================================================= /// Returns paginated clock entries. Managers can pass any userId; others only see their own. [HttpGet] public async Task 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); } /// Manager-only: edit clock-in/out times or notes on any entry in the company. [HttpPost] public async Task 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 }); } /// Manager-only: soft-delete a clock entry. [HttpPost] public async Task 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 }); } /// /// Closes the current open Work entry and opens a Break or Lunch entry. /// The caller must have an open Work segment; calling while already on a break returns 400. /// Multi-punches-per-day setting is intentionally bypassed — taking a break is inherently /// a second punch in the same day and should always be allowed. /// [HttpPost] public async Task GoOnBreak([FromBody] GoOnBreakRequest request) { if (request.BreakType is not (ClockEntryType.Break or ClockEntryType.Lunch)) return BadRequest(new { message = "Invalid break type." }); 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 BadRequest(new { message = "You are not currently clocked in." }); if (openEntry.EntryType != ClockEntryType.Work) return BadRequest(new { message = "You are already on a break." }); var now = DateTime.UtcNow; // Close the work segment openEntry.ClockOutTime = now; openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2); // Open the break/lunch segment var breakEntry = new EmployeeClockEntry { UserId = userId, CompanyId = companyId, ClockInTime = now, EntryType = request.BreakType }; await _unitOfWork.EmployeeClockEntries.AddAsync(breakEntry); await _unitOfWork.CompleteAsync(); _logger.LogInformation("User {UserId} started {BreakType} (entry {EntryId})", userId, request.BreakType, breakEntry.Id); return Json(new { success = true, entryId = breakEntry.Id }); } /// /// Closes the current open Break/Lunch entry and opens a new Work entry. /// The caller must have an open Break or Lunch segment; calling while working returns 400. /// [HttpPost] public async Task ReturnFromBreak() { 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 || openEntry.EntryType == ClockEntryType.Work) return BadRequest(new { message = "You are not currently on a break." }); var now = DateTime.UtcNow; // Close the break segment openEntry.ClockOutTime = now; openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2); // Open a new work segment var workEntry = new EmployeeClockEntry { UserId = userId, CompanyId = companyId, ClockInTime = now, EntryType = ClockEntryType.Work }; await _unitOfWork.EmployeeClockEntries.AddAsync(workEntry); await _unitOfWork.CompleteAsync(); _logger.LogInformation("User {UserId} returned from {BreakType} (new entry {EntryId})", userId, openEntry.EntryType, workEntry.Id); return Json(new { success = true, entryId = workEntry.Id }); } /// /// Manager-only: create a manual time entry for any active employee in the company. /// Used to record missed punches, paper timesheets, or schedule corrections. /// ClockInTime and ClockOutTime are expected as UTC ISO strings from the JS client. /// [HttpPost] public async Task ManualEntry([FromBody] ManualEntryRequest request) { if (!await IsManagerAsync()) return Forbid(); var companyId = GetCurrentCompanyId(); // Verify the target user belongs to this company var targetUser = await _userManager.FindByIdAsync(request.UserId); if (targetUser == null || targetUser.CompanyId != companyId || !targetUser.IsActive) return NotFound(new { message = "Employee not found." }); if (request.ClockOutTime.HasValue && request.ClockOutTime.Value <= request.ClockInTime) return BadRequest(new { message = "Clock-out time must be after clock-in time." }); var entry = new EmployeeClockEntry { UserId = request.UserId, CompanyId = companyId, ClockInTime = request.ClockInTime, ClockOutTime = request.ClockOutTime, Notes = request.Notes?.Trim() }; if (request.ClockOutTime.HasValue) entry.HoursWorked = Math.Round( (decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2); await _unitOfWork.EmployeeClockEntries.AddAsync(entry); await _unitOfWork.CompleteAsync(); _logger.LogInformation( "Manager {ManagerId} created manual entry for {TargetUserId} (entry {EntryId})", _userManager.GetUserId(User), request.UserId, entry.Id); return Json(new { success = true, entryId = entry.Id }); } // ========================================================================= // KIOSK — device activation // ========================================================================= /// /// 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(); return View(); } /// /// 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 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(); 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. /// Validates the cookie token against rows (supports multiple tablets per company). /// [AllowAnonymous] [HttpGet("Timeclock/Kiosk")] public async Task Kiosk() { 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."); if (!company.TimeclockEnabled) return View("KioskError", "Timeclock is currently disabled for this company."); await TouchDeviceAsync(device); ViewBag.CompanyName = company.CompanyName; ViewBag.CompanyId = company.Id; return View(); } /// /// 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. /// [AllowAnonymous, HttpGet] public async Task KioskEmployees() { 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 .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); } /// /// 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. /// [AllowAnonymous, HttpPost] public async Task KioskPunch([FromBody] KioskPunchRequest request) { var cookie = ReadTimeclockKioskCookie(); if (cookie == null) return Forbid(); 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." }); // 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; // 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); 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 { // 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 { 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) // ========================================================================= /// Sets or clears a 4-digit kiosk PIN for the specified employee. Manager-only. [HttpPost] public async Task 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 // ========================================================================= /// /// 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; return int.TryParse(claim, out int id) ? id : 0; } private async Task 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, EntryType = e.EntryType, 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(); } }