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
+133 -51
View File
@@ -22,13 +22,13 @@
<p>
The Timeclock module tracks facility-level employee attendance &mdash; when workers arrive and
leave the shop each day. It is separate from <em>Job Time Entries</em> (which log hours billed
against a specific job). Timeclock is about payroll and attendance; Job Time Entries are about
job costing.
against a specific job). Timeclock is about payroll and attendance; Job Time Entries are about job costing.
</p>
<p>
Multiple clock-in/clock-out pairs per day are fully supported. Employees can clock out for
lunch, clock back in, clock out for a break, and so on. Each segment is recorded separately
with its own in/out times and duration. Daily and weekly totals are computed automatically.
Each punch creates a <strong>segment</strong> with a type: <strong>Work</strong>, <strong>Break</strong>,
or <strong>Lunch</strong>. Only Work segments count toward paid-hours totals &mdash; break and lunch
time appears in the attendance history for reference but is excluded from day totals, weekly totals,
and the payroll CSV export.
</p>
<p>
Find Timeclock under <strong>Shop Floor &rsaquo; Timeclock</strong> in the left sidebar.
@@ -37,20 +37,44 @@
<section id="clocking-in-out" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-arrow-in-right text-success me-2"></i>Clocking In and Out
<i class="bi bi-box-arrow-in-right text-success me-2"></i>Clocking In, Out, and Breaks
</h2>
<p>
From the Timeclock dashboard, your current status card shows whether you are clocked in or out
and how long the current segment has been running.
Your <strong>My Clock Status</strong> card on the Timeclock dashboard shows your current state
and the available actions. The buttons change depending on what you are currently doing.
</p>
<h5 class="fw-semibold mt-3">Starting and ending your day</h5>
<ul>
<li><strong>Clock In</strong> &mdash; click the green &ldquo;Clock In&rdquo; button when you arrive. Your start time is recorded immediately as a Work segment.</li>
<li><strong>Clock Out</strong> &mdash; click the red &ldquo;Clock Out&rdquo; button when you leave for the day. Hours worked for that segment are calculated and saved.</li>
</ul>
<h5 class="fw-semibold mt-3">Taking a break or lunch <span class="badge bg-secondary fw-normal small">requires multiple-punches setting</span></h5>
<p>
When you are clocked in and working, two additional buttons appear (if your company has
<em>Allow Multiple Punches Per Day</em> enabled in Timeclock Settings):
</p>
<ul>
<li><strong>Clock In</strong> &mdash; click the green &ldquo;Clock In&rdquo; button. Your start time is recorded immediately.</li>
<li><strong>Clock Out</strong> &mdash; click the red &ldquo;Clock Out&rdquo; button. Hours worked for that segment are calculated and saved.</li>
<li><strong>Multiple segments per day</strong> &mdash; after clocking out, just clock in again when you return. Each segment is stored separately.</li>
<li>
<strong><i class="bi bi-cup-hot"></i> Break</strong> &mdash; closes your current Work segment
and opens a Break segment. Your status changes to <span class="badge bg-warning text-dark">On Break</span>.
</li>
<li>
<strong><i class="bi bi-cup-straw"></i> Lunch</strong> &mdash; same as Break but marks the
segment as Lunch. Your status changes to <span class="badge bg-info text-dark">At Lunch</span>.
</li>
</ul>
<p>
While on a break or at lunch, a single <strong>Return to Work</strong> button is shown.
Clicking it closes the break/lunch segment and immediately opens a new Work segment &mdash;
you are working again without any extra steps.
</p>
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
You cannot clock in while already clocked in. You will see an error if you try &mdash; clock out first.
You cannot have two open segments at the same time. The Break/Lunch buttons automatically
close your current Work segment before opening the break segment, so there is never a gap
or overlap in your record.
</div>
</section>
@@ -59,9 +83,17 @@
<i class="bi bi-people-fill text-primary me-2"></i>Who&rsquo;s In
</h2>
<p>
The &ldquo;Who&rsquo;s In&rdquo; card on the dashboard shows everyone currently clocked in, with
their start time and elapsed hours. It refreshes automatically every 60&nbsp;seconds &mdash;
no manual reload needed.
The <strong>Who&rsquo;s In</strong> card shows everyone with an open segment right now, along
with their current status:
</p>
<ul>
<li><span class="badge bg-success">Working</span> &mdash; actively on the clock</li>
<li><span class="badge bg-warning text-dark">Break</span> &mdash; on a short break</li>
<li><span class="badge bg-info text-dark">Lunch</span> &mdash; at lunch</li>
</ul>
<p>
Elapsed time for break and lunch segments is displayed in muted text to distinguish it from
productive work time. The card refreshes automatically every 60&nbsp;seconds.
</p>
</section>
@@ -72,10 +104,15 @@
<p>
The Timeclock Kiosk is a tablet-friendly page designed to live on a shared device in the shop.
Employees do not need to log in &mdash; they tap their name tile and enter a 4-digit PIN.
The system automatically clocks them in (if currently out) or out (if currently in). A confirmation
screen shows the time, segment duration, and today&rsquo;s running total, then returns to the
employee grid after 4&nbsp;seconds.
The system automatically clocks them in (if currently out) or out (if currently in).
A confirmation screen shows the time, segment duration, and today&rsquo;s running total,
then returns to the employee grid after 4&nbsp;seconds.
</p>
<div class="alert alert-warning alert-permanent">
<i class="bi bi-exclamation-triangle me-2"></i>
The kiosk performs a simple toggle &mdash; it does not have dedicated Break/Lunch buttons.
Use the main Timeclock dashboard for break and lunch tracking.
</div>
<h5 class="fw-semibold mt-3">Activating the kiosk</h5>
<ol>
<li>On the tablet, log in as a Manager or Company Admin.</li>
@@ -84,19 +121,19 @@
<li>From now on, <code>/Timeclock/Kiosk</code> works without login on that device.</li>
</ol>
<p>
If the tablet is lost or replaced, re-visit <strong>Activate Kiosk</strong> on a new device. The
old token is revoked automatically. To deactivate without re-activating, ask a Manager to clear the token from Company Settings.
Multiple tablets per company are supported &mdash; each tablet gets its own token.
To deactivate a tablet, go to <strong>Settings &rsaquo; Company &rsaquo; Timeclock</strong>
and click <strong>Deactivate</strong> next to the device.
</p>
<h5 class="fw-semibold mt-3">Setting employee PINs</h5>
<ol>
<li>Go to <strong>Settings &rsaquo; Users</strong> and click the employee&rsquo;s name.</li>
<li>Scroll to the <strong>Timeclock Kiosk PIN</strong> section at the bottom of the Edit User page.</li>
<li>Go to <strong>Settings &rsaquo; Users</strong> and open the employee&rsquo;s Edit page.</li>
<li>Scroll to the <strong>Timeclock Kiosk PIN</strong> section.</li>
<li>Enter a 4-digit PIN and click <strong>Set PIN</strong>.</li>
<li>To disable an employee on the kiosk, click <strong>Clear</strong>.</li>
</ol>
<p>
Only employees with a PIN set will appear on the kiosk employee grid. Employees without a PIN
cannot use the kiosk tablet.
Only employees with a PIN set will appear on the kiosk employee grid.
</p>
</section>
@@ -108,21 +145,38 @@
Managers and Company Admins have additional controls on the Timeclock dashboard.
Workers cannot edit or delete their own entries &mdash; all corrections must go through a Manager or Company Admin.
</p>
<h5 class="fw-semibold mt-3">Edit an entry</h5>
<p>
Click the <i class="bi bi-pencil"></i> pencil icon next to any entry in <strong>My Recent History</strong>
or the <strong>Team History</strong> table. Adjust the clock-in time, clock-out time, or notes.
Hours worked are recalculated automatically on save.
</p>
<h5 class="fw-semibold mt-3">Delete an entry</h5>
<p>
Click the <i class="bi bi-trash"></i> trash icon. The entry is soft-deleted and excluded from
all totals and reports immediately.
</p>
<h5 class="fw-semibold mt-3">Add a manual time entry</h5>
<p>
Forgot to clock in? Paper timesheet to enter? Click <strong><i class="bi bi-plus-circle"></i> Manual Entry</strong>
in the Team History card header to open the manual entry modal. Select the employee, enter
the clock-in time, an optional clock-out time, and optional notes, then click <strong>Save Entry</strong>.
</p>
<ul>
<li>
<strong>Edit an entry</strong> &mdash; click the pencil icon next to any entry in the
My Recent History table or the Team History table. Adjust clock-in time, clock-out time,
or notes. Hours worked are recalculated automatically on save.
</li>
<li>
<strong>Delete an entry</strong> &mdash; click the trash icon. The entry is soft-deleted
and excluded from all totals and reports.
</li>
<li>
<strong>Team History</strong> &mdash; the manager section at the bottom of the dashboard
lets you load any date range for all employees, with full punch detail and edit/delete controls.
</li>
<li>You can create an <em>open</em> entry (no clock-out) if the employee is still on the clock.</li>
<li>Manual entries are always created as Work segments.</li>
<li>Only employees in your company appear in the employee dropdown.</li>
</ul>
<h5 class="fw-semibold mt-3">Team History</h5>
<p>
The manager section at the bottom of the dashboard lets you load any date range for all
employees, with full punch detail including segment type (Work / Break / Lunch) and
edit/delete controls on every row.
</p>
</section>
<section id="reports" class="mb-5">
@@ -130,26 +184,54 @@
<i class="bi bi-bar-chart-line text-info me-2"></i>Attendance Report &amp; CSV Export
</h2>
<p>
Go to <strong>Reports &rsaquo; Attendance</strong> for a date-range attendance summary.
Each employee&rsquo;s card shows every daily punch segment and a day total, plus
a weekly subtotal row at the bottom of each ISO week. Managers see all employees;
workers see only their own history.
Go to <strong>Reports &rsaquo; Attendance</strong> for a structured attendance summary.
Managers see all employees; workers see only their own history.
</p>
<h5 class="fw-semibold mt-3">Choosing a period</h5>
<p>
Click <strong>Export CSV (Payroll)</strong> to download a flat CSV file of the same
date range. Each row is one clock segment and includes:
The report defaults to the <strong>current week</strong>. Use the toggle and dropdown at
the top to switch between views:
</p>
<ul>
<li>Employee Name</li>
<li>Date and Day of Week</li>
<li>Clock In and Clock Out times</li>
<li>Segment Hours, Day Total Hours, and Week Total Hours</li>
<li>
<strong>Week</strong> &mdash; choose any of the last 12 weeks from the dropdown.
&ldquo;This Week&rdquo; and &ldquo;Last Week&rdquo; are pre-labelled for quick access.
Weekly subtotal rows are hidden in week view (they would just repeat the total).
</li>
<li>
<strong>Month</strong> &mdash; choose any of the last 12 months. Weekly subtotal rows
appear inside each employee card so you can see how hours are distributed across weeks.
</li>
</ul>
<p>
The dropdown auto-applies on change &mdash; no separate &ldquo;Filter&rdquo; button needed.
</p>
<h5 class="fw-semibold mt-3">Hours totals and break exclusion</h5>
<p>
Day totals, weekly subtotals, and the employee total badge count <strong>Work segments only</strong>.
Break and Lunch segments appear in the table (labelled with a badge) but their hours are
shown in muted text and do not roll up into any total. This ensures the report is
payroll-ready without manual adjustments.
</p>
<h5 class="fw-semibold mt-3">Payroll CSV export</h5>
<p>
Click <strong>Export CSV (Payroll)</strong> to download a flat file for the current period.
Each row is one clock segment. Columns:
</p>
<ul>
<li>Employee Name, Date, Day of Week</li>
<li><strong>Type</strong> &mdash; Work, Break, or Lunch</li>
<li>Clock In, Clock Out, Segment Hours</li>
<li>Day Total Hours, Week Total Hours &mdash; populated on Work rows only; blank on Break/Lunch rows</li>
<li>Notes</li>
</ul>
<p>
The CSV is designed to be imported directly into payroll software or handed off to a
payroll provider. Every row is self-contained (no merged cells or subtotal-only rows)
so it works with Excel, Google Sheets, and most payroll import wizards.
The CSV is self-contained (one row per segment, no merged cells) and imports directly into
Excel, Google Sheets, and most payroll software. The filename includes the period label
(e.g. <code>Acme_Attendance_Week_of_May_26-Jun_1_2026.csv</code>).
</p>
</section>
@@ -161,7 +243,7 @@
<h6 class="fw-bold mb-3">On This Page</h6>
<ul class="list-unstyled small">
<li><a href="#overview" class="text-decoration-none">Overview</a></li>
<li><a href="#clocking-in-out" class="text-decoration-none">Clocking In &amp; Out</a></li>
<li><a href="#clocking-in-out" class="text-decoration-none">Clocking In, Out &amp; Breaks</a></li>
<li><a href="#whos-in" class="text-decoration-none">Who&rsquo;s In</a></li>
<li><a href="#kiosk" class="text-decoration-none">Shop Floor Kiosk</a></li>
<li><a href="#manager-tools" class="text-decoration-none">Manager Tools</a></li>
@@ -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>
}
@@ -1,15 +1,20 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Enums
@using PowderCoating.Application.DTOs.Timeclock
@{
ViewData["Title"] = "Timeclock";
ViewData["PageIcon"] = "bi-clock-history";
var currentUser = ViewBag.CurrentUser as ApplicationUser;
var openEntry = ViewBag.OpenEntry as EmployeeClockEntryDto;
var activeEntries = (ViewBag.ActiveEntries as List<EmployeeClockEntryDto>) ?? new List<EmployeeClockEntryDto>();
var myHistory = (ViewBag.MyHistory as List<EmployeeClockEntry>) ?? new List<EmployeeClockEntry>();
var isManager = (bool)(ViewBag.IsManager ?? false);
var nowUtc = (DateTime)(ViewBag.NowUtc ?? DateTime.UtcNow);
var isClockedIn = openEntry != null;
var currentUser = ViewBag.CurrentUser as ApplicationUser;
var openEntry = ViewBag.OpenEntry as EmployeeClockEntryDto;
var activeEntries = (ViewBag.ActiveEntries as List<EmployeeClockEntryDto>) ?? new List<EmployeeClockEntryDto>();
var myHistory = (ViewBag.MyHistory as List<EmployeeClockEntry>) ?? new List<EmployeeClockEntry>();
var isManager = (bool)(ViewBag.IsManager ?? false);
var nowUtc = (DateTime)(ViewBag.NowUtc ?? DateTime.UtcNow);
var isClockedIn = openEntry != null;
var companyUsers = (ViewBag.CompanyUsers as List<(string Id, string Name)>) ?? new List<(string, string)>();
var allowMultiplePunches = (bool)(ViewBag.AllowMultiplePunches ?? true);
var isOnBreak = isClockedIn && openEntry!.EntryType != ClockEntryType.Work;
var isWorking = isClockedIn && openEntry!.EntryType == ClockEntryType.Work;
}
@* ── Metric strip ───────────────────────────────────────────────────────────── *@
@@ -20,7 +25,8 @@
<div class="pcl-metric-strip-cell">
@{
var todayStart = nowUtc.Date;
var myToday = myHistory.Where(e => e.ClockInTime >= todayStart);
// Only Work segments count toward paid hours; break/lunch are excluded
var myToday = myHistory.Where(e => e.ClockInTime >= todayStart && e.EntryType == ClockEntryType.Work);
var todayHours = myToday.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
}
@await Html.PartialAsync("_Metric", (Label: "MY HOURS TODAY", Value: Math.Round(todayHours, 1).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
@@ -28,7 +34,7 @@
<div class="pcl-metric-strip-cell">
@{
var weekStart = nowUtc.Date.AddDays(-(int)nowUtc.DayOfWeek);
var weekHours = myHistory.Where(e => e.ClockInTime >= weekStart)
var weekHours = myHistory.Where(e => e.ClockInTime >= weekStart && e.EntryType == ClockEntryType.Work)
.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
}
@await Html.PartialAsync("_Metric", (Label: "MY HOURS THIS WEEK", Value: Math.Round(weekHours, 1).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
@@ -45,19 +51,7 @@
</div>
<div class="card-body text-center py-4">
<div id="clock-status-display">
@if (isClockedIn)
{
<div class="mb-3">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
Clocked In
</span>
</div>
<p class="text-muted mb-1">Since</p>
<p class="fw-semibold mb-3">@openEntry!.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
<p class="text-muted small mb-4">Entry #@openEntry.Id</p>
}
else
@if (!isClockedIn)
{
<div class="mb-3">
<span class="badge bg-secondary fs-6 px-3 py-2">
@@ -67,21 +61,74 @@
</div>
<p class="text-muted mb-4">You are not currently clocked in.</p>
}
else if (isWorking)
{
<div class="mb-3">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
Working
</span>
</div>
<p class="text-muted mb-1">Since</p>
<p class="fw-semibold mb-3">@openEntry!.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
<p class="text-muted small mb-4">Entry #@openEntry.Id</p>
}
else if (openEntry!.EntryType == ClockEntryType.Break)
{
<div class="mb-3">
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
<i class="bi bi-cup-hot-fill me-1"></i>On Break
</span>
</div>
<p class="text-muted mb-1">Break started</p>
<p class="fw-semibold mb-4">@openEntry.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
}
else
{
<div class="mb-3">
<span class="badge bg-info text-dark fs-6 px-3 py-2">
<i class="bi bi-cup-straw me-1"></i>At Lunch
</span>
</div>
<p class="text-muted mb-1">Lunch started</p>
<p class="fw-semibold mb-4">@openEntry!.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
}
</div>
@if (isClockedIn)
{
<button id="btn-clock-out" class="btn btn-danger btn-lg w-100"
data-entry-id="@openEntry!.Id">
<i class="bi bi-box-arrow-right me-2"></i>Clock Out
</button>
}
else
@if (!isClockedIn)
{
<button id="btn-clock-in" class="btn btn-success btn-lg w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Clock In
</button>
}
else if (isWorking)
{
<div class="d-grid gap-2">
@if (allowMultiplePunches)
{
<div class="d-flex gap-2">
<button id="btn-break" class="btn btn-outline-warning flex-fill"
data-break-type="1" title="Start a short break">
<i class="bi bi-cup-hot me-1"></i>Break
</button>
<button id="btn-lunch" class="btn btn-outline-info flex-fill"
data-break-type="2" title="Start your lunch period">
<i class="bi bi-cup-straw me-1"></i>Lunch
</button>
</div>
}
<button id="btn-clock-out" class="btn btn-danger btn-lg w-100"
data-entry-id="@openEntry!.Id">
<i class="bi bi-box-arrow-right me-2"></i>Clock Out
</button>
</div>
}
else
{
<button id="btn-return-from-break" class="btn btn-success btn-lg w-100">
<i class="bi bi-arrow-return-left me-2"></i>Return to Work
</button>
}
<div id="clock-feedback" class="mt-3 d-none"></div>
</div>
@@ -124,6 +171,7 @@
<thead class="table-light">
<tr>
<th>Date</th>
<th>Type</th>
<th>Clock In</th>
<th>Clock Out</th>
<th>Hours</th>
@@ -134,7 +182,9 @@
<tbody>
@foreach (var day in grouped)
{
var dayTotal = day.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
// Only Work segments count toward the day total displayed here
var dayTotal = day.Where(e => e.EntryType == ClockEntryType.Work)
.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
var segments = day.OrderBy(e => e.ClockInTime).ToList();
@foreach (var entry in segments)
@@ -147,6 +197,14 @@
<br /><small class="text-muted">@Math.Round(dayTotal, 2)h total</small>
</td>
}
<td>
@if (entry.EntryType == ClockEntryType.Break)
{ <span class="badge bg-warning text-dark">Break</span> }
else if (entry.EntryType == ClockEntryType.Lunch)
{ <span class="badge bg-info text-dark">Lunch</span> }
else
{ <span class="badge bg-success">Work</span> }
</td>
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (entry.ClockOutTime.HasValue)
@@ -205,6 +263,11 @@
<button id="btn-manager-load" class="btn btn-sm btn-outline-primary">
<i class="bi bi-search me-1"></i>Load
</button>
<button id="btn-manual-entry" class="btn btn-sm btn-outline-success"
data-bs-toggle="modal" data-bs-target="#manualEntryModal"
title="Add manual time entry for an employee">
<i class="bi bi-plus-circle me-1"></i>Manual Entry
</button>
</div>
</div>
<div class="card-body p-0" id="manager-history-container">
@@ -216,6 +279,56 @@
</div>
@* ── Manual Entry Modal ──────────────────────────────────────────────────────── *@
@if (isManager)
{
<div class="modal fade" id="manualEntryModal" tabindex="-1" aria-labelledby="manualEntryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="manualEntryModalLabel">
<i class="bi bi-plus-circle me-2"></i>Manual Time Entry
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold" for="manual-employee">Employee</label>
<select id="manual-employee" class="form-select">
<option value="">-- Select Employee --</option>
@foreach (var u in companyUsers)
{
<option value="@u.Id">@u.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="manual-clock-in">Clock In</label>
<input type="datetime-local" id="manual-clock-in" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="manual-clock-out">Clock Out</label>
<input type="datetime-local" id="manual-clock-out" class="form-control" />
<div class="form-text">Leave blank to create an open (in-progress) entry.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="manual-notes">Notes</label>
<input type="text" id="manual-notes" class="form-control" maxlength="500"
placeholder="e.g. Forgot to clock in, paper timesheet" />
</div>
<div id="manual-entry-feedback" class="d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="btn-save-manual-entry">
<i class="bi bi-check-lg me-1"></i>Save Entry
</button>
</div>
</div>
</div>
</div>
}
@* ── Edit Entry Modal ────────────────────────────────────────────────────────── *@
@if (isManager)
{
@@ -255,9 +368,11 @@
<script src="~/js/timeclock.js" asp-append-version="true"></script>
<script>
Timeclock.init({
isClockedIn: @Json.Serialize(isClockedIn),
openEntryId: @Json.Serialize(openEntry?.Id),
isManager: @Json.Serialize(isManager)
isClockedIn: @Json.Serialize(isClockedIn),
openEntryId: @Json.Serialize(openEntry?.Id),
isManager: @Json.Serialize(isManager),
isOnBreak: @Json.Serialize(isOnBreak),
allowMultiplePunches: @Json.Serialize(allowMultiplePunches)
});
</script>
}
@@ -1,3 +1,4 @@
@using PowderCoating.Core.Enums
@model List<PowderCoating.Application.DTOs.Timeclock.EmployeeClockEntryDto>
@{
var nowUtc = DateTime.UtcNow;
@@ -13,7 +14,8 @@ else
<thead class="table-light">
<tr>
<th>Employee</th>
<th>Clocked In Since</th>
<th>Status</th>
<th>Since</th>
<th>Elapsed</th>
</tr>
</thead>
@@ -23,8 +25,28 @@ else
var elapsed = (nowUtc - entry.ClockInTime).TotalHours;
<tr>
<td class="fw-semibold">@entry.UserDisplayName</td>
<td>
@if (entry.EntryType == ClockEntryType.Break)
{
<span class="badge bg-warning text-dark">
<i class="bi bi-cup-hot me-1"></i>Break
</span>
}
else if (entry.EntryType == ClockEntryType.Lunch)
{
<span class="badge bg-info text-dark">
<i class="bi bi-cup-straw me-1"></i>Lunch
</span>
}
else
{
<span class="badge bg-success">
<i class="bi bi-circle-fill me-1" style="font-size:.45rem;vertical-align:middle;"></i>Working
</span>
}
</td>
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td><span class="text-success">@Math.Round(elapsed, 1)h</span></td>
<td><span class="@(entry.EntryType == ClockEntryType.Work ? "text-success" : "text-muted")">@Math.Round(elapsed, 1)h</span></td>
</tr>
}
</tbody>