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:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user