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
@@ -2484,7 +2484,7 @@ public class ReportsController : Controller
/// Managers can see all employees; Workers see their own history only.
/// </summary>
[HttpGet]
public async Task<IActionResult> Attendance(DateTime? from, DateTime? to)
public async Task<IActionResult> Attendance(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
@@ -2492,13 +2492,12 @@ public class ReportsController : Controller
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var period = ResolveAttendancePeriod(mode, week, month);
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& e.ClockInTime >= period.Start
&& e.ClockInTime < period.End
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
@@ -2509,22 +2508,22 @@ public class ReportsController : Controller
{
UserId = g.Key,
DisplayName = g.First().User?.FullName ?? "Unknown",
TotalHours = Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2),
// Only Work segments count toward paid hours
TotalHours = Math.Round(g.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Days = g.GroupBy(e => e.ClockInTime.Date)
.OrderByDescending(d => d.Key)
.Select(d => new AttendanceDayRow
{
Date = d.Key,
DayTotal = Math.Round(d.Sum(e => e.HoursWorked ?? 0), 2),
Segments = d.OrderBy(e => e.ClockInTime).ToList()
Date = d.Key,
DayTotal = Math.Round(d.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Segments = d.OrderBy(e => e.ClockInTime).ToList()
}).ToList()
})
.OrderBy(r => r.DisplayName)
.ToList();
ViewBag.From = start;
ViewBag.To = end.AddDays(-1);
ViewBag.IsManager = isManager;
ViewBag.Period = period;
ViewBag.IsManager = isManager;
return View(grouped);
}
@@ -2535,7 +2534,7 @@ public class ReportsController : Controller
/// every row so the file is fully self-contained in flat-file format.
/// </summary>
[HttpGet]
public async Task<IActionResult> AttendanceCsv(DateTime? from, DateTime? to)
public async Task<IActionResult> AttendanceCsv(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
@@ -2543,8 +2542,9 @@ public class ReportsController : Controller
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var period = ResolveAttendancePeriod(mode, week, month);
var start = period.Start;
var end = period.End;
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
@@ -2560,8 +2560,10 @@ public class ReportsController : Controller
.ThenBy(e => e.ClockInTime)
.ToList();
// Pre-compute day totals and week totals per user
var dayTotals = entries
// Day/week totals count only Work segments (Break/Lunch are unpaid)
var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();
var dayTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
@@ -2569,37 +2571,41 @@ public class ReportsController : Controller
static int IsoWeek(DateTime d) =>
System.Globalization.ISOWeek.GetWeekOfYear(d);
var weekTotals = entries
var weekTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
var sb = new System.Text.StringBuilder();
sb.AppendLine("Employee Name,Date,Day of Week,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
sb.AppendLine("Employee Name,Date,Day of Week,Type,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
foreach (var entry in rows)
{
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
var clockOut = entry.ClockOutTime.HasValue
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
var entryType = entry.EntryType.ToString(); // Work, Break, or Lunch
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
var clockOut = entry.ClockOutTime.HasValue
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
: "In Progress";
var segHrs = entry.HoursWorked.HasValue
var segHrs = entry.HoursWorked.HasValue
? entry.HoursWorked.Value.ToString("F2")
: "";
// Day/week totals are blank for Break/Lunch rows — they only appear on Work rows
var isWork = entry.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work;
var dayKey = (entry.UserId, entry.ClockInTime.Date);
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
var dayTotal = dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
var wkTotal = weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
var dayTotal = isWork && dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
var wkTotal = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
var notes = CsvEscape(entry.Notes ?? "");
sb.AppendLine($"{name},{date},{dow},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
sb.AppendLine($"{name},{date},{dow},{entryType},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
var filename = $"{safeName}_Attendance_{start:yyyyMMdd}_to_{end.AddDays(-1):yyyyMMdd}.csv";
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
var safePeriod = period.PeriodLabel.Replace(" ", "_").Replace("", "-").Replace(",", "");
var filename = $"{safeName}_Attendance_{safePeriod}.csv";
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
}
@@ -2611,6 +2617,89 @@ public class ReportsController : Controller
return value;
}
// =========================================================================
// Attendance period helpers
// =========================================================================
/// <summary>
/// Resolves query start/end dates and picker display state from the attendance
/// report's mode/week/month URL params. Defaults to the current ISO week.
/// Shared by <see cref="Attendance"/> and <see cref="AttendanceCsv"/> so both
/// actions always query exactly the same date range for a given URL.
/// </summary>
private static AttendancePeriod ResolveAttendancePeriod(string? mode, string? week, string? month)
{
if (mode?.ToLower() == "month")
{
var monthDate = month != null
&& DateTime.TryParseExact(month + "-01", "yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var md)
? md
: new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
return new AttendancePeriod
{
Mode = "month",
Start = monthDate,
End = monthDate.AddMonths(1),
WeekValue = IsoWeekValue(DateTime.UtcNow),
MonthValue = monthDate.ToString("yyyy-MM"),
PeriodLabel = monthDate.ToString("MMMM yyyy")
};
}
else
{
var weekStart = TryParseIsoWeek(week) ?? CurrentIsoWeekStart();
var weekEnd = weekStart.AddDays(6);
var label = weekStart.Year != weekEnd.Year
? $"{weekStart:MMM d, yyyy} {weekEnd:MMM d, yyyy}"
: weekStart.Month != weekEnd.Month
? $"Week of {weekStart:MMM d} {weekEnd:MMM d, yyyy}"
: $"Week of {weekStart:MMMM d} {weekEnd:d, yyyy}";
return new AttendancePeriod
{
Mode = "week",
Start = weekStart,
End = weekStart.AddDays(7),
WeekValue = IsoWeekValue(weekStart),
MonthValue = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).ToString("yyyy-MM"),
PeriodLabel = label
};
}
}
/// <summary>Returns the Monday of the current ISO week in UTC.</summary>
private static DateTime CurrentIsoWeekStart()
{
var today = DateTime.UtcNow.Date;
return System.Globalization.ISOWeek.ToDateTime(
System.Globalization.ISOWeek.GetYear(today),
System.Globalization.ISOWeek.GetWeekOfYear(today),
DayOfWeek.Monday);
}
/// <summary>Formats a date as the ISO week string used by the HTML week picker (e.g. "2026-W22").</summary>
private static string IsoWeekValue(DateTime d) =>
$"{System.Globalization.ISOWeek.GetYear(d):D4}-W{System.Globalization.ISOWeek.GetWeekOfYear(d):D2}";
/// <summary>Parses an ISO week string (e.g. "2026-W22") to the Monday of that week. Returns null on failure.</summary>
private static DateTime? TryParseIsoWeek(string? value)
{
if (value == null) return null;
var parts = value.Split("-W");
if (parts.Length == 2
&& int.TryParse(parts[0], out int yr) && yr is >= 2020 and <= 2100
&& int.TryParse(parts[1], out int wk) && wk is >= 1 and <= 53)
{
try { return System.Globalization.ISOWeek.ToDateTime(yr, wk, DayOfWeek.Monday); }
catch { /* invalid week number for that year */ }
}
return null;
}
/// <summary>
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
/// company name into AI prompts so the generated text refers to the actual business, not a
@@ -2779,3 +2868,15 @@ public class AttendanceDayRow
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
}
/// <summary>Resolved date range and picker state for the attendance report.</summary>
public record AttendancePeriod
{
public string Mode { get; init; } = "week";
public DateTime Start { get; init; }
/// <summary>Exclusive upper bound for EF queries (Start + 7 days or +1 month).</summary>
public DateTime End { get; init; }
public string WeekValue { get; init; } = ""; // "2026-W22"
public string MonthValue { get; init; } = ""; // "2026-05"
public string PeriodLabel { get; init; } = "";
}
@@ -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
};