Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export

- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries)
- KioskPin added to ApplicationUser; TimeclockKioskToken added to Company
- TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete,
  tablet kiosk with device-cookie auth, PIN management via Users edit page
- Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out
- Attendance report at /Reports/Attendance with weekly subtotal rows
- Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment)
- AllowCustomFormulas wired through PlatformSubscriptionController + subscription views
- Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating)
- Help article (Help/Timeclock.cshtml) and AI knowledge base updated
- Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:53:13 -04:00
parent f625be01a3
commit 6c2fe6e1c4
40 changed files with 24125 additions and 16 deletions
@@ -2475,6 +2475,142 @@ public class ReportsController : Controller
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
}
// =========================================================================
// Timeclock / Attendance
// =========================================================================
/// <summary>
/// Attendance report: daily punch detail and weekly summaries per employee.
/// Managers can see all employees; Workers see their own history only.
/// </summary>
[HttpGet]
public async Task<IActionResult> Attendance(DateTime? from, DateTime? to)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
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 entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
var grouped = entries
.GroupBy(e => e.UserId)
.Select(g => new AttendanceEmployeeRow
{
UserId = g.Key,
DisplayName = g.First().User?.FullName ?? "Unknown",
TotalHours = Math.Round(g.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()
}).ToList()
})
.OrderBy(r => r.DisplayName)
.ToList();
ViewBag.From = start;
ViewBag.To = end.AddDays(-1);
ViewBag.IsManager = isManager;
return View(grouped);
}
/// <summary>
/// Exports the attendance data for the selected date range as a CSV file suitable for
/// import into payroll software. Each row is one clock segment (punch pair); employee name,
/// date, clock in/out times, segment hours, day total, and week total are all repeated on
/// every row so the file is fully self-contained in flat-file format.
/// </summary>
[HttpGet]
public async Task<IActionResult> AttendanceCsv(DateTime? from, DateTime? to)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
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 entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
// Build flat CSV rows sorted by employee name → date → clock-in time
var rows = entries
.OrderBy(e => e.User?.FullName ?? "")
.ThenBy(e => e.ClockInTime)
.ToList();
// Pre-compute day totals and week totals per user
var dayTotals = entries
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
// ISO week: group by (UserId, year, week number) where week starts Monday
static int IsoWeek(DateTime d) =>
System.Globalization.ISOWeek.GetWeekOfYear(d);
var weekTotals = entries
.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");
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
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
: "In Progress";
var segHrs = entry.HoursWorked.HasValue
? entry.HoursWorked.Value.ToString("F2")
: "";
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 notes = CsvEscape(entry.Notes ?? "");
sb.AppendLine($"{name},{date},{dow},{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";
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
/// <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
@@ -2628,3 +2764,18 @@ public class Vendor1099Row
public bool NeedsForm { get; set; }
}
public class AttendanceEmployeeRow
{
public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public decimal TotalHours { get; set; }
public List<AttendanceDayRow> Days { get; set; } = new();
}
public class AttendanceDayRow
{
public DateTime Date { get; set; }
public decimal DayTotal { get; set; }
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
}