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
@@ -106,11 +106,14 @@
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
@if (ViewBag.AllowCustomFormulas == true)
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="custom-formulas-tab" data-bs-toggle="tab" data-bs-target="#custom-formulas" type="button" role="tab">
<i class="bi bi-calculator"></i> Custom Formulas
</button>
</li>
}
</ul>
<!-- Tabs Content -->
@@ -2060,6 +2063,8 @@
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
@if (ViewBag.AllowCustomFormulas == true)
{
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -2098,6 +2103,7 @@
</div>
</div>
</div>
}
</div>
</div>
@@ -2172,7 +2178,7 @@
<label class="form-label">Formula <span class="text-danger">*</span></label>
<input type="text" id="cfFormula" class="form-control font-monospace" placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate" />
<div class="form-text mt-1">
<span class="me-1">Available variables for this formula:</span>
<span class="me-1">Variables (click to insert):</span>
<span id="cfVariablePills"></span>
</div>
</div>
@@ -256,6 +256,34 @@
</div>
</div>
@* ── Kiosk PIN ─────────────────────────────────────────────── *@
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom mt-4">
<h5 class="card-title mb-0">Timeclock Kiosk PIN</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Kiosk PIN"
data-bs-content="Set a 4-digit PIN so this employee can clock in and out on the shared shop tablet. Leave blank to disable kiosk access for this employee.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label">New PIN <span class="text-muted small">(4 digits &mdash; blank to disable)</span></label>
<div class="input-group">
<input type="password" id="kiosk-pin-input" class="form-control" maxlength="4"
placeholder="&bull;&bull;&bull;&bull;" inputmode="numeric" pattern="\d{4}"
autocomplete="new-password" />
<button type="button" class="btn btn-outline-secondary" id="btn-set-pin">
<i class="bi bi-key me-1"></i>Set PIN
</button>
<button type="button" class="btn btn-outline-danger" id="btn-clear-pin">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
<div id="pin-feedback" class="form-text mt-1"></div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
@if (!string.IsNullOrEmpty(ViewBag.ReturnUrl))
{
@@ -277,6 +305,55 @@
</div>
</div>
@section Scripts {
<script>
(function () {
var userId = '@Model.Id';
var antiForgery = document.querySelector('input[name="__RequestVerificationToken"]');
var token = antiForgery ? antiForgery.value : '';
function pinPost(pin) {
var body = new URLSearchParams();
body.append('userId', userId);
if (pin !== null) body.append('pin', pin);
return fetch('/Timeclock/SetKioskPin', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: body.toString()
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Error'; });
return r.json();
});
}
document.getElementById('btn-set-pin').addEventListener('click', function () {
var val = document.getElementById('kiosk-pin-input').value.trim();
if (!/^\d{4}$/.test(val)) {
setFeedback('PIN must be exactly 4 digits.', 'danger');
return;
}
pinPost(val).then(function () {
setFeedback('PIN set successfully.', 'success');
document.getElementById('kiosk-pin-input').value = '';
}).catch(function (e) { setFeedback(e, 'danger'); });
});
document.getElementById('btn-clear-pin').addEventListener('click', function () {
if (!confirm('Clear this employee&apos;s kiosk PIN? They will no longer be able to use the tablet timeclock.')) return;
pinPost(null).then(function () {
setFeedback('PIN cleared. Employee is now disabled on the kiosk.', 'success');
}).catch(function (e) { setFeedback(e, 'danger'); });
});
function setFeedback(msg, type) {
var el = document.getElementById('pin-feedback');
el.textContent = msg;
el.className = 'form-text mt-1 text-' + type;
}
})();
</script>
}
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
@@ -205,6 +205,22 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-clock-history text-success fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Timeclock</h5>
<p class="card-text text-muted small mb-2">Track employee attendance with daily clock-in/clock-out. Multi-segment days, tablet kiosk mode with PIN, and attendance reports.</p>
<a asp-controller="Help" asp-action="Timeclock" class="btn btn-sm btn-outline-success">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reports & Admin -->
@@ -0,0 +1,173 @@
@{
ViewData["Title"] = "Timeclock";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Timeclock</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<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.
</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.
</p>
<p>
Find Timeclock under <strong>Shop Floor &rsaquo; Timeclock</strong> in the left sidebar.
</p>
</section>
<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
</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.
</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>
</ul>
<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.
</div>
</section>
<section id="whos-in" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<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.
</p>
</section>
<section id="kiosk" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tablet me-2"></i>Shop Floor Kiosk
</h2>
<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.
</p>
<h5 class="fw-semibold mt-3">Activating the kiosk</h5>
<ol>
<li>On the tablet, log in as a Manager or Company Admin.</li>
<li>Navigate to <strong>Timeclock &rsaquo; Activate Kiosk</strong> (or go to <code>/Timeclock/Kiosk/Activate</code>).</li>
<li>The system generates a device token and writes a secure cookie to that browser.</li>
<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.
</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>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.
</p>
</section>
<section id="manager-tools" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-shield-check text-warning me-2"></i>Manager Tools
</h2>
<p>
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>
<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>
</ul>
</section>
<section id="reports" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<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.
</p>
<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:
</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>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.
</p>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
<div class="card shadow-sm sticky-top" style="top:80px;">
<div class="card-body">
<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="#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>
<li><a href="#reports" class="text-decoration-none">Attendance Report &amp; CSV</a></li>
</ul>
</div>
</div>
</div>
</div>
@@ -145,6 +145,19 @@
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">Custom Formulas</h5>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="AllowCustomFormulas" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowCustomFormulas" class="form-check-label fw-medium">Allow Custom Formula Templates</label>
</div>
<div class="form-text">
When enabled, companies on this plan can create reusable NCalc pricing formula templates
(roof curbs, enclosures, custom fabrications) and use them in quotes and jobs.
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">AI Features</h5>
<div class="mb-3">
@@ -182,6 +182,19 @@
}
</td>
</tr>
<tr>
<td class="text-muted">Custom Formulas</td>
<td>
@if (plan.AllowCustomFormulas)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
</tr>
@@ -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>
@@ -1270,6 +1270,10 @@ var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports")
<span>Maintenance</span>
</a>
}
<a asp-controller="Timeclock" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-clock-history"></i>
<span>Timeclock</span>
</a>
@* ── Reports & Templates ──────────────────────────────────── *@
@if (hasReports || hasJobs)
@@ -97,6 +97,7 @@
<tr>
<th>Plan</th>
<th class="text-center">Online Payments (Stripe Connect)</th>
<th class="text-center">Custom Formula Templates</th>
</tr>
</thead>
<tbody>
@@ -114,12 +115,23 @@
title="Toggle online payment capability for @p.DisplayName plan" />
</div>
</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input plan-feature-toggle"
type="checkbox" role="switch"
data-plan-id="@p.Id"
data-feature="AllowCustomFormulas"
@(p.AllowCustomFormulas ? "checked" : "")
title="Toggle custom formula templates for @p.DisplayName plan" />
</div>
</td>
</tr>
}
</tbody>
</table>
<div class="px-3 py-2 text-muted" style="font-size:0.75rem;">
Changes take effect immediately. Companies on a plan with Online Payments enabled can connect their Stripe account in Company Settings.
Custom Formula Templates allow companies to define reusable pricing formulas in Company Settings &rarr; Custom Formulas.
</div>
</div>
</div>
@@ -0,0 +1,263 @@
@using PowderCoating.Core.Entities
@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;
}
@* ── Metric strip ───────────────────────────────────────────────────────────── *@
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "WHO'S IN", Value: activeEntries.Count.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@{
var todayStart = nowUtc.Date;
var myToday = myHistory.Where(e => e.ClockInTime >= todayStart);
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))
</div>
<div class="pcl-metric-strip-cell">
@{
var weekStart = nowUtc.Date.AddDays(-(int)nowUtc.DayOfWeek);
var weekHours = myHistory.Where(e => e.ClockInTime >= weekStart)
.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))
</div>
</div>
<div class="row g-4">
@* ── My Clock Status ───────────────────────────────────────────────────── *@
<div class="col-12 col-lg-4">
<div class="card h-100 shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-person-badge me-2"></i>My Clock Status
</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
{
<div class="mb-3">
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-circle me-1" style="font-size:.5rem;vertical-align:middle;"></i>
Clocked Out
</span>
</div>
<p class="text-muted mb-4">You are not currently clocked in.</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
{
<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>
}
<div id="clock-feedback" class="mt-3 d-none"></div>
</div>
</div>
</div>
@* ── Who's In ───────────────────────────────────────────────────────────── *@
<div class="col-12 col-lg-8">
<div class="card h-100 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center fw-semibold">
<span><i class="bi bi-people-fill me-2"></i>Who's In <span id="whos-in-count" class="badge bg-success ms-1">@activeEntries.Count</span></span>
<small class="text-muted fw-normal" id="whos-in-updated"></small>
</div>
<div class="card-body p-0">
<div id="whos-in-container">
@await Html.PartialAsync("_WhosIn", activeEntries)
</div>
</div>
</div>
</div>
@* ── My Recent History ──────────────────────────────────────────────────── *@
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-calendar3 me-2"></i>My Recent History (Last 14 Days)
</div>
<div class="card-body p-0">
@if (!myHistory.Any())
{
<div class="text-muted text-center py-4">No clock entries in the last 14 days.</div>
}
else
{
var grouped = myHistory
.GroupBy(e => e.ClockInTime.ToLocalTime().Date)
.OrderByDescending(g => g.Key);
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th>Hours</th>
<th>Notes</th>
@if (isManager) { <th class="text-end">Actions</th> }
</tr>
</thead>
<tbody>
@foreach (var day in grouped)
{
var dayTotal = day.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
var segments = day.OrderBy(e => e.ClockInTime).ToList();
@foreach (var entry in segments)
{
<tr>
@if (entry == segments.First())
{
<td class="fw-semibold" rowspan="@segments.Count">
@day.Key.ToString("ddd M/d")
<br /><small class="text-muted">@Math.Round(dayTotal, 2)h total</small>
</td>
}
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (entry.ClockOutTime.HasValue)
{ @entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt") }
else
{ <span class="badge bg-success">In Progress</span> }
</td>
<td>
@if (entry.HoursWorked.HasValue)
{ @entry.HoursWorked.Value.ToString("F2") }
else
{
var elapsed = (decimal)(nowUtc - entry.ClockInTime).TotalHours;
<span class="text-success">@Math.Round(elapsed, 2)h</span>
}
</td>
<td class="text-muted small">@entry.Notes</td>
@if (isManager)
{
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary btn-edit-entry"
data-id="@entry.Id"
data-clockin="@entry.ClockInTime.ToString("o")"
data-clockout="@(entry.ClockOutTime?.ToString("o") ?? "")"
data-notes="@(entry.Notes ?? "")">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-xs btn-outline-danger btn-delete-entry"
data-id="@entry.Id">
<i class="bi bi-trash"></i>
</button>
</td>
}
</tr>
}
}
</tbody>
</table>
}
</div>
</div>
</div>
@* ── Manager: All Employees / History ───────────────────────────────────── *@
@if (isManager)
{
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center fw-semibold">
<span><i class="bi bi-table me-2"></i>Team History</span>
<div class="d-flex gap-2 align-items-center">
<input type="date" id="manager-from" class="form-control form-control-sm" style="width:140px"
value="@DateTime.Today.AddDays(-7).ToString("yyyy-MM-dd")" />
<input type="date" id="manager-to" class="form-control form-control-sm" style="width:140px"
value="@DateTime.Today.ToString("yyyy-MM-dd")" />
<button id="btn-manager-load" class="btn btn-sm btn-outline-primary">
<i class="bi bi-search me-1"></i>Load
</button>
</div>
</div>
<div class="card-body p-0" id="manager-history-container">
<div class="text-muted text-center py-4 small">Select a date range and click Load.</div>
</div>
</div>
</div>
}
</div>
@* ── Edit Entry Modal ────────────────────────────────────────────────────────── *@
@if (isManager)
{
<div class="modal fade" id="editEntryModal" tabindex="-1" aria-labelledby="editEntryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editEntryModalLabel">Edit Clock Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-entry-id" />
<div class="mb-3">
<label class="form-label fw-semibold">Clock In</label>
<input type="datetime-local" id="edit-clock-in" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Clock Out</label>
<input type="datetime-local" id="edit-clock-out" class="form-control" />
<div class="form-text">Leave blank if still clocked in.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<input type="text" id="edit-notes" class="form-control" maxlength="500" />
</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-primary" id="btn-save-edit">Save Changes</button>
</div>
</div>
</div>
</div>
}
@section Scripts {
<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)
});
</script>
}
@@ -0,0 +1,101 @@
@{
Layout = "_KioskLayout";
ViewData["Title"] = "Employee Timeclock";
var companyName = ViewBag.CompanyName as string ?? "Timeclock";
}
<style>
.tc-employee-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.tc-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
padding: 1.25rem .75rem;
background: var(--bs-body-bg);
border: 2px solid var(--bs-border-color);
border-radius: 1rem;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
position: relative;
}
.tc-tile:hover { border-color: #0d6efd; box-shadow: 0 0 0 3px rgba(13,110,253,.2); }
.tc-tile.clocked-in { border-color: #198754; }
.tc-avatar {
width: 64px; height: 64px; border-radius: 50%;
background: #6c757d; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; font-weight: 700;
}
.tc-tile.clocked-in .tc-avatar { background: #198754; }
.tc-name { font-size: .9rem; font-weight: 600; text-align: center; line-height: 1.2; }
.tc-status-badge {
font-size: .7rem; padding: .15rem .5rem; border-radius: .5rem;
background: #198754; color: #fff;
}
/* PIN pad */
#tc-pin-screen { display: none; }
.tc-pin-display {
font-size: 2rem; letter-spacing: .5rem; min-height: 2.5rem;
text-align: center; font-weight: 700;
}
.tc-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; max-width: 240px; margin: 0 auto; }
.tc-key { font-size: 1.5rem; padding: .75rem; border-radius: .5rem; }
/* Confirmation */
#tc-confirm-screen { display: none; }
.tc-confirm-icon { font-size: 4rem; }
</style>
@* ── Employee Grid ────────────────────────────────────────────────────────────── *@
<div id="tc-employee-screen">
<div class="text-center mb-4">
<h4 class="fw-bold mb-0">@companyName &mdash; Timeclock</h4>
<p class="text-muted small">Tap your name to clock in or out</p>
</div>
<div id="tc-employee-grid" class="tc-employee-grid">
<div class="text-center py-4 text-muted">Loading&hellip;</div>
</div>
</div>
@* ── PIN Entry Screen ─────────────────────────────────────────────────────────── *@
<div id="tc-pin-screen">
<div class="text-center mb-4">
<h5 id="tc-pin-name" class="fw-bold"></h5>
<p class="text-muted small">Enter your 4-digit PIN</p>
</div>
<div class="tc-pin-display mb-3" id="tc-pin-display">&bull;&bull;&bull;&bull;</div>
<div id="tc-pin-error" class="alert alert-danger text-center d-none mb-3"></div>
<div class="tc-keypad mb-4">
@foreach (var n in new[]{"1","2","3","4","5","6","7","8","9","&larr;","0","&check;"})
{
var cls = n switch {
"&larr;" => "btn btn-warning tc-key",
"&check;" => "btn btn-success tc-key",
_ => "btn btn-outline-secondary tc-key"
};
<button class="@cls tc-keypad-btn" data-key="@n">@Html.Raw(n)</button>
}
</div>
<div class="text-center">
<button class="btn btn-link text-muted" id="tc-back-btn">&larr; Back to employees</button>
</div>
</div>
@* ── Confirmation Screen ──────────────────────────────────────────────────────── *@
<div id="tc-confirm-screen" class="text-center py-3">
<div id="tc-confirm-icon" class="tc-confirm-icon mb-3"></div>
<h4 id="tc-confirm-title" class="fw-bold mb-1"></h4>
<p id="tc-confirm-time" class="text-muted mb-2"></p>
<p id="tc-confirm-today" class="fw-semibold"></p>
<p class="text-muted small mt-3" id="tc-dismiss-msg">Returning to employee list in <span id="tc-countdown">4</span>s&hellip;</p>
</div>
@section Scripts {
<script src="~/js/timeclock-kiosk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,12 @@
@model string
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Unable to Start";
}
<div class="text-center py-5">
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:4rem;"></i>
<h2 class="mt-3 fw-bold">Timeclock Unavailable</h2>
<p class="text-muted mt-2">@Html.Raw(Model)</p>
<p class="mt-4 text-muted" style="font-size:.9rem;">Please ask a manager for assistance.</p>
</div>
@@ -0,0 +1,32 @@
@model List<PowderCoating.Application.DTOs.Timeclock.EmployeeClockEntryDto>
@{
var nowUtc = DateTime.UtcNow;
}
@if (!Model.Any())
{
<div class="text-muted text-center py-4">No employees currently clocked in.</div>
}
else
{
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Employee</th>
<th>Clocked In Since</th>
<th>Elapsed</th>
</tr>
</thead>
<tbody>
@foreach (var entry in Model)
{
var elapsed = (nowUtc - entry.ClockInTime).TotalHours;
<tr>
<td class="fw-semibold">@entry.UserDisplayName</td>
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td><span class="text-success">@Math.Round(elapsed, 1)h</span></td>
</tr>
}
</tbody>
</table>
}