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