9dd36238bb
- ClockEntryType enum (Work/Break/Lunch) on EmployeeClockEntry; default 0 = Work so all existing entries are unaffected - Migration AddClockEntryType applied - Break and Lunch buttons on clock status card (only when AllowMultiplePunchesPerDay is enabled); GoOnBreak closes current Work segment and opens Break/Lunch segment - Return to Work button when on break/lunch; closes break segment, opens new Work - Status badges on clock card and Who'\''s In grid: Working / On Break / At Lunch - Break/Lunch hours excluded from all day totals, week totals, metrics, and CSV - Manager: Manual Entry modal to create a time entry for any company employee - Attendance report defaults to current ISO week; Week/Month mode toggle with auto-submitting dropdowns (last 12 weeks or months); period label shown inline - Attendance CSV: Type column added; day/week totals blank on Break/Lunch rows; filename uses period label - Week subtotal rows suppressed in single-week view (shown in month view only) - Help article and AI knowledge base updated for all new features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
817 lines
33 KiB
C#
817 lines
33 KiB
C#
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;
|
|
|
|
/// <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;
|
|
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
|
|
// =========================================================================
|
|
|
|
/// <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,
|
|
entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch
|
|
});
|
|
|
|
return Json(result);
|
|
}
|
|
|
|
/// <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 = 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 });
|
|
}
|
|
|
|
/// <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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> 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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> 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
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// 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();
|
|
return View();
|
|
}
|
|
|
|
/// <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 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();
|
|
|
|
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.
|
|
/// 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 (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();
|
|
}
|
|
|
|
/// <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();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <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 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)
|
|
// =========================================================================
|
|
|
|
/// <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
|
|
// =========================================================================
|
|
|
|
/// <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;
|
|
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,
|
|
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();
|
|
}
|
|
}
|