Add timeclock break/lunch tracking, manual entries, and attendance period picker

- 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>
This commit is contained in:
2026-05-27 09:30:39 -04:00
parent 8ae61b6c78
commit 9dd36238bb
14 changed files with 11909 additions and 181 deletions
@@ -5,6 +5,7 @@ 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;
@@ -55,6 +56,7 @@ public class TimeclockController : Controller
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(
@@ -73,12 +75,28 @@ public class TimeclockController : Controller
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;
// 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();
}
@@ -125,7 +143,8 @@ public class TimeclockController : Controller
userId = e.UserId,
displayName = e.User?.FullName ?? "Unknown",
clockInTime = e.ClockInTime,
entryId = e.Id
entryId = e.Id,
entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch
});
return Json(result);
@@ -294,6 +313,130 @@ public class TimeclockController : Controller
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
// =========================================================================
@@ -660,6 +803,7 @@ public class TimeclockController : Controller
ClockInTime = e.ClockInTime,
ClockOutTime = e.ClockOutTime,
HoursWorked = e.HoursWorked,
EntryType = e.EntryType,
Notes = e.Notes
};