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:
@@ -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") ?? "—")</td>
|
||||
<td class="text-end">
|
||||
@if (seg.EntryType != ClockEntryType.Work)
|
||||
{
|
||||
<span class="text-muted">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "—")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "—")
|
||||
}
|
||||
</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") – @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") – @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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user