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
@@ -1,41 +1,112 @@
@using PowderCoating.Core.Enums
@using System.Globalization
@model List<AttendanceEmployeeRow>
@{
ViewData["Title"] = "Attendance Report";
ViewData["PageIcon"] = "bi-clock-history";
var from = (DateTime)ViewBag.From;
var to = (DateTime)ViewBag.To;
var period = (AttendancePeriod)ViewBag.Period;
var isManager = (bool)(ViewBag.IsManager ?? false);
var isWeek = period.Mode == "week";
// ISO week number helper
int IsoWeek(DateTime d) => System.Globalization.ISOWeek.GetWeekOfYear(d);
// ISO week number helper (used for week subtotal grouping below)
int IsoWeek(DateTime d) => ISOWeek.GetWeekOfYear(d);
// ── Build week dropdown options: current week + 11 prior weeks ─────────
var todayWeekStart = ISOWeek.ToDateTime(
ISOWeek.GetYear(DateTime.UtcNow.Date),
ISOWeek.GetWeekOfYear(DateTime.UtcNow.Date),
DayOfWeek.Monday);
var weekOptions = Enumerable.Range(0, 12).Select(i =>
{
var ws = todayWeekStart.AddDays(-i * 7);
var we = ws.AddDays(6);
var val = $"{ISOWeek.GetYear(ws):D4}-W{ISOWeek.GetWeekOfYear(ws):D2}";
// Keep labels short so text doesn't crowd the dropdown caret
string lbl;
if (i == 0) lbl = $"This Week {ws:M/d}{we:M/d}";
else if (i == 1) lbl = $"Last Week {ws:M/d}{we:M/d}";
else lbl = $"{ws:M/d}{we:M/d/yy}";
return (val, lbl);
}).ToList();
// ── Build month dropdown options: current month + 11 prior months ──────
var thisMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
var monthOptions = Enumerable.Range(0, 12).Select(i =>
{
var m = thisMonth.AddMonths(-i);
var val = m.ToString("yyyy-MM");
var lbl = i == 0 ? $"This Month {m:MMM yyyy}" :
i == 1 ? $"Last Month {m:MMM yyyy}" :
m.ToString("MMM yyyy");
return (val, lbl);
}).ToList();
}
<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
@* ── Period picker ────────────────────────────────────────────────────────── *@
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
@* Mode toggle *@
<div class="btn-group btn-group-sm" role="group" id="attendance-mode-group">
<button type="button" id="btn-mode-week"
class="btn @(isWeek ? "btn-primary" : "btn-outline-primary")">
<i class="bi bi-calendar-week me-1"></i>Week
</button>
<button type="button" id="btn-mode-month"
class="btn @(!isWeek ? "btn-primary" : "btn-outline-primary")">
<i class="bi bi-calendar-month me-1"></i>Month
</button>
</div>
@* Week selector (visible in week mode) *@
<form method="get" id="form-week" class="@(!isWeek ? "d-none" : "")">
<input type="hidden" name="mode" value="week" />
<select name="week" class="form-select form-select-sm" style="width:230px"
onchange="this.form.submit()" title="Select week">
@foreach (var (val, lbl) in weekOptions)
{
if (val == period.WeekValue)
{ <option value="@val" selected>@lbl</option> }
else
{ <option value="@val">@lbl</option> }
}
</select>
</form>
@* Month selector (visible in month mode) *@
<form method="get" id="form-month" class="@(isWeek ? "d-none" : "")">
<input type="hidden" name="mode" value="month" />
<select name="month" class="form-select form-select-sm" style="width:210px"
onchange="this.form.submit()" title="Select month">
@foreach (var (val, lbl) in monthOptions)
{
if (val == period.MonthValue)
{ <option value="@val" selected>@lbl</option> }
else
{ <option value="@val">@lbl</option> }
}
</select>
</form>
@* Period label *@
<span class="fw-semibold text-muted small">@period.PeriodLabel</span>
</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>
@* Export CSV — passes same mode/period params *@
<a asp-action="AttendanceCsv"
asp-route-mode="@period.Mode"
asp-route-week="@(isWeek ? period.WeekValue : null)"
asp-route-month="@(!isWeek ? period.MonthValue : null)"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export CSV (Payroll)
</a>
</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.
<i class="bi bi-info-circle me-2"></i>No clock entries found for <strong>@period.PeriodLabel</strong>.
</div>
}
else
@@ -48,7 +119,10 @@ else
@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))
@{
var dayCount = (period.End - period.Start).Days;
}
@await Html.PartialAsync("_Metric", (Label: "DAYS IN PERIOD", Value: dayCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
@@ -72,6 +146,7 @@ else
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Clock In</th>
<th>Clock Out</th>
<th class="text-end">Segment Hrs</th>
@@ -82,7 +157,6 @@ else
@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);
@@ -99,6 +173,20 @@ else
@day.Date.ToString("ddd M/d/yy")
</td>
}
<td>
@if (seg.EntryType == ClockEntryType.Break)
{
<span class="badge bg-warning text-dark">Break</span>
}
else if (seg.EntryType == ClockEntryType.Lunch)
{
<span class="badge bg-info text-dark">Lunch</span>
}
else
{
<span class="badge bg-success">Work</span>
}
</td>
<td>@seg.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (seg.ClockOutTime.HasValue)
@@ -106,7 +194,16 @@ else
else
{ <span class="badge bg-success">In Progress</span> }
</td>
<td class="text-end">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "&mdash;")</td>
<td class="text-end">
@if (seg.EntryType != ClockEntryType.Work)
{
<span class="text-muted">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "&mdash;")</span>
}
else
{
@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "&mdash;")
}
</td>
@if (i == 0)
{
<td class="text-end fw-semibold" rowspan="@segs.Count">
@@ -117,12 +214,16 @@ else
}
}
<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>
@* Show week subtotal row only when viewing a month (multiple weeks visible) *@
@if (!isWeek)
{
<tr class="table-secondary">
<td colspan="5" 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>
@@ -130,3 +231,19 @@ else
</div>
}
}
@section Scripts {
<script>
// Toggle between week and month forms when the mode buttons are clicked
document.getElementById('btn-mode-week').addEventListener('click', function () {
document.getElementById('form-week').classList.remove('d-none');
document.getElementById('form-month').classList.add('d-none');
document.getElementById('form-week').submit();
});
document.getElementById('btn-mode-month').addEventListener('click', function () {
document.getElementById('form-month').classList.remove('d-none');
document.getElementById('form-week').classList.add('d-none');
document.getElementById('form-month').submit();
});
</script>
}