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
@@ -0,0 +1,132 @@
@model List<AttendanceEmployeeRow>
@{
ViewData["Title"] = "Attendance Report";
ViewData["PageIcon"] = "bi-clock-history";
var from = (DateTime)ViewBag.From;
var to = (DateTime)ViewBag.To;
var isManager = (bool)(ViewBag.IsManager ?? false);
// ISO week number helper
int IsoWeek(DateTime d) => System.Globalization.ISOWeek.GetWeekOfYear(d);
}
<div class="row mb-3 align-items-end">
<div class="col">
<form method="get" class="d-flex align-items-center gap-2 flex-wrap">
<label class="fw-semibold">From</label>
<input type="date" name="from" class="form-control form-control-sm" style="width:150px" value="@from.ToString("yyyy-MM-dd")" />
<label class="fw-semibold">To</label>
<input type="date" name="to" class="form-control form-control-sm" style="width:150px" value="@to.ToString("yyyy-MM-dd")" />
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-search me-1"></i>Filter
</button>
</form>
</div>
<div class="col-auto">
<a asp-action="AttendanceCsv"
asp-route-from="@from.ToString("yyyy-MM-dd")"
asp-route-to="@to.ToString("yyyy-MM-dd")"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export CSV (Payroll)
</a>
</div>
</div>
@if (!Model.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No clock entries found for the selected date range.
</div>
}
else
{
<div class="pcl-metric-strip mb-4">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "EMPLOYEES", Value: Model.Count.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL HOURS", Value: Model.Sum(r => r.TotalHours).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "DAYS COVERED", Value: ((to - from).Days + 1).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
@foreach (var employee in Model)
{
// Group days into ISO weeks for week subtotals
var weekGroups = employee.Days
.GroupBy(d => (d.Date.Year, IsoWeek(d.Date)))
.OrderByDescending(g => g.Key)
.ToList();
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-person me-2"></i>@employee.DisplayName
</span>
<span class="badge bg-primary">@employee.TotalHours.ToString("F2") hrs total</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th class="text-end">Segment Hrs</th>
<th class="text-end">Day Total</th>
</tr>
</thead>
<tbody>
@foreach (var wg in weekGroups)
{
var weekTotal = wg.Sum(d => d.DayTotal);
// First day of week group (chronologically earliest) for label
var weekStart = wg.Min(d => d.Date);
var weekEnd = wg.Max(d => d.Date);
@foreach (var day in wg.OrderByDescending(d => d.Date))
{
var segs = day.Segments;
@for (int i = 0; i < segs.Count; i++)
{
var seg = segs[i];
<tr>
@if (i == 0)
{
<td class="fw-semibold" rowspan="@segs.Count">
@day.Date.ToString("ddd M/d/yy")
</td>
}
<td>@seg.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (seg.ClockOutTime.HasValue)
{ @seg.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt") }
else
{ <span class="badge bg-success">In Progress</span> }
</td>
<td class="text-end">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "&mdash;")</td>
@if (i == 0)
{
<td class="text-end fw-semibold" rowspan="@segs.Count">
@day.DayTotal.ToString("F2")h
</td>
}
</tr>
}
}
<tr class="table-secondary">
<td colspan="4" class="text-end text-muted small fst-italic">
Week of @weekStart.ToString("M/d") &ndash; @weekEnd.ToString("M/d")
</td>
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
@@ -331,6 +331,14 @@
<p>Monthly revenue, job count, and average order value trends with breakdowns by customer type and priority.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="Attendance" class="report-card">
<div class="report-card-icon" style="background:#ecfdf5;color:#059669;">
<i class="bi bi-clock-history"></i>
</div>
<h5>Attendance</h5>
<p>Daily punch detail and total hours per employee. See who clocked in, when, and how long each segment lasted.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>