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,3 +1,5 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Timeclock;
public class EmployeeClockEntryDto
@@ -8,6 +10,7 @@ public class EmployeeClockEntryDto
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public decimal? HoursWorked { get; set; }
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public bool IsOpen => ClockOutTime == null;
}
@@ -41,6 +44,25 @@ public class EditClockEntryRequest
public string? Notes { get; set; }
}
/// <summary>
/// Sent when an employee clicks Break or Lunch to pause their work segment.
/// The server closes the current Work entry and opens a Break/Lunch entry.
/// </summary>
public class GoOnBreakRequest
{
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
public ClockEntryType BreakType { get; set; }
}
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
public class ManualEntryRequest
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
public class KioskEmployeeDto
{
@@ -1,3 +1,5 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
@@ -18,6 +20,12 @@ public class EmployeeClockEntry : BaseEntity
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
public decimal? HoursWorked { get; set; }
/// <summary>
/// Whether this segment is regular work time, a break, or a lunch period.
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
/// </summary>
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public virtual ApplicationUser User { get; set; } = null!;
@@ -0,0 +1,17 @@
namespace PowderCoating.Core.Enums;
/// <summary>
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
/// </summary>
public enum ClockEntryType
{
/// <summary>Normal productive work time (default).</summary>
Work = 0,
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
Break = 1,
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
Lunch = 2
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddClockEntryType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EntryType",
table: "EmployeeClockEntries",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EntryType",
table: "EmployeeClockEntries");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
}
}
}
@@ -3075,6 +3075,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("EntryType")
.HasColumnType("int");
b.Property<decimal?>("HoursWorked")
.HasColumnType("decimal(18,2)");
@@ -6865,7 +6868,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772),
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6876,7 +6879,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777),
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6887,7 +6890,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779),
CreatedAt = new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -2484,7 +2484,7 @@ public class ReportsController : Controller
/// Managers can see all employees; Workers see their own history only.
/// </summary>
[HttpGet]
public async Task<IActionResult> Attendance(DateTime? from, DateTime? to)
public async Task<IActionResult> Attendance(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
@@ -2492,13 +2492,12 @@ public class ReportsController : Controller
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var period = ResolveAttendancePeriod(mode, week, month);
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& e.ClockInTime >= period.Start
&& e.ClockInTime < period.End
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
@@ -2509,22 +2508,22 @@ public class ReportsController : Controller
{
UserId = g.Key,
DisplayName = g.First().User?.FullName ?? "Unknown",
TotalHours = Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2),
// Only Work segments count toward paid hours
TotalHours = Math.Round(g.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Days = g.GroupBy(e => e.ClockInTime.Date)
.OrderByDescending(d => d.Key)
.Select(d => new AttendanceDayRow
{
Date = d.Key,
DayTotal = Math.Round(d.Sum(e => e.HoursWorked ?? 0), 2),
Segments = d.OrderBy(e => e.ClockInTime).ToList()
Date = d.Key,
DayTotal = Math.Round(d.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Segments = d.OrderBy(e => e.ClockInTime).ToList()
}).ToList()
})
.OrderBy(r => r.DisplayName)
.ToList();
ViewBag.From = start;
ViewBag.To = end.AddDays(-1);
ViewBag.IsManager = isManager;
ViewBag.Period = period;
ViewBag.IsManager = isManager;
return View(grouped);
}
@@ -2535,7 +2534,7 @@ public class ReportsController : Controller
/// every row so the file is fully self-contained in flat-file format.
/// </summary>
[HttpGet]
public async Task<IActionResult> AttendanceCsv(DateTime? from, DateTime? to)
public async Task<IActionResult> AttendanceCsv(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
@@ -2543,8 +2542,9 @@ public class ReportsController : Controller
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var period = ResolveAttendancePeriod(mode, week, month);
var start = period.Start;
var end = period.End;
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
@@ -2560,8 +2560,10 @@ public class ReportsController : Controller
.ThenBy(e => e.ClockInTime)
.ToList();
// Pre-compute day totals and week totals per user
var dayTotals = entries
// Day/week totals count only Work segments (Break/Lunch are unpaid)
var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();
var dayTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
@@ -2569,37 +2571,41 @@ public class ReportsController : Controller
static int IsoWeek(DateTime d) =>
System.Globalization.ISOWeek.GetWeekOfYear(d);
var weekTotals = entries
var weekTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
var sb = new System.Text.StringBuilder();
sb.AppendLine("Employee Name,Date,Day of Week,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
sb.AppendLine("Employee Name,Date,Day of Week,Type,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
foreach (var entry in rows)
{
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
var clockOut = entry.ClockOutTime.HasValue
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
var entryType = entry.EntryType.ToString(); // Work, Break, or Lunch
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
var clockOut = entry.ClockOutTime.HasValue
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
: "In Progress";
var segHrs = entry.HoursWorked.HasValue
var segHrs = entry.HoursWorked.HasValue
? entry.HoursWorked.Value.ToString("F2")
: "";
// Day/week totals are blank for Break/Lunch rows — they only appear on Work rows
var isWork = entry.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work;
var dayKey = (entry.UserId, entry.ClockInTime.Date);
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
var dayTotal = dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
var wkTotal = weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
var dayTotal = isWork && dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
var wkTotal = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
var notes = CsvEscape(entry.Notes ?? "");
sb.AppendLine($"{name},{date},{dow},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
sb.AppendLine($"{name},{date},{dow},{entryType},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
var filename = $"{safeName}_Attendance_{start:yyyyMMdd}_to_{end.AddDays(-1):yyyyMMdd}.csv";
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
var safePeriod = period.PeriodLabel.Replace(" ", "_").Replace("", "-").Replace(",", "");
var filename = $"{safeName}_Attendance_{safePeriod}.csv";
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
}
@@ -2611,6 +2617,89 @@ public class ReportsController : Controller
return value;
}
// =========================================================================
// Attendance period helpers
// =========================================================================
/// <summary>
/// Resolves query start/end dates and picker display state from the attendance
/// report's mode/week/month URL params. Defaults to the current ISO week.
/// Shared by <see cref="Attendance"/> and <see cref="AttendanceCsv"/> so both
/// actions always query exactly the same date range for a given URL.
/// </summary>
private static AttendancePeriod ResolveAttendancePeriod(string? mode, string? week, string? month)
{
if (mode?.ToLower() == "month")
{
var monthDate = month != null
&& DateTime.TryParseExact(month + "-01", "yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var md)
? md
: new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
return new AttendancePeriod
{
Mode = "month",
Start = monthDate,
End = monthDate.AddMonths(1),
WeekValue = IsoWeekValue(DateTime.UtcNow),
MonthValue = monthDate.ToString("yyyy-MM"),
PeriodLabel = monthDate.ToString("MMMM yyyy")
};
}
else
{
var weekStart = TryParseIsoWeek(week) ?? CurrentIsoWeekStart();
var weekEnd = weekStart.AddDays(6);
var label = weekStart.Year != weekEnd.Year
? $"{weekStart:MMM d, yyyy} {weekEnd:MMM d, yyyy}"
: weekStart.Month != weekEnd.Month
? $"Week of {weekStart:MMM d} {weekEnd:MMM d, yyyy}"
: $"Week of {weekStart:MMMM d} {weekEnd:d, yyyy}";
return new AttendancePeriod
{
Mode = "week",
Start = weekStart,
End = weekStart.AddDays(7),
WeekValue = IsoWeekValue(weekStart),
MonthValue = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).ToString("yyyy-MM"),
PeriodLabel = label
};
}
}
/// <summary>Returns the Monday of the current ISO week in UTC.</summary>
private static DateTime CurrentIsoWeekStart()
{
var today = DateTime.UtcNow.Date;
return System.Globalization.ISOWeek.ToDateTime(
System.Globalization.ISOWeek.GetYear(today),
System.Globalization.ISOWeek.GetWeekOfYear(today),
DayOfWeek.Monday);
}
/// <summary>Formats a date as the ISO week string used by the HTML week picker (e.g. "2026-W22").</summary>
private static string IsoWeekValue(DateTime d) =>
$"{System.Globalization.ISOWeek.GetYear(d):D4}-W{System.Globalization.ISOWeek.GetWeekOfYear(d):D2}";
/// <summary>Parses an ISO week string (e.g. "2026-W22") to the Monday of that week. Returns null on failure.</summary>
private static DateTime? TryParseIsoWeek(string? value)
{
if (value == null) return null;
var parts = value.Split("-W");
if (parts.Length == 2
&& int.TryParse(parts[0], out int yr) && yr is >= 2020 and <= 2100
&& int.TryParse(parts[1], out int wk) && wk is >= 1 and <= 53)
{
try { return System.Globalization.ISOWeek.ToDateTime(yr, wk, DayOfWeek.Monday); }
catch { /* invalid week number for that year */ }
}
return null;
}
/// <summary>
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
/// company name into AI prompts so the generated text refers to the actual business, not a
@@ -2779,3 +2868,15 @@ public class AttendanceDayRow
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
}
/// <summary>Resolved date range and picker state for the attendance report.</summary>
public record AttendancePeriod
{
public string Mode { get; init; } = "week";
public DateTime Start { get; init; }
/// <summary>Exclusive upper bound for EF queries (Start + 7 days or +1 month).</summary>
public DateTime End { get; init; }
public string WeekValue { get; init; } = ""; // "2026-W22"
public string MonthValue { get; init; } = ""; // "2026-05"
public string PeriodLabel { get; init; } = "";
}
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Timeclock;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
@@ -55,6 +56,7 @@ public class TimeclockController : Controller
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
// Current user's open entry (null = clocked out)
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
@@ -73,12 +75,28 @@ public class TimeclockController : Controller
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
ViewBag.CurrentUser = currentUser;
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
ViewBag.IsManager = isManager;
ViewBag.NowUtc = DateTime.UtcNow;
// Load company employees for the manual-entry dropdown (managers only)
List<(string Id, string Name)> companyUsers = new();
if (isManager)
{
companyUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToListAsync()
.ContinueWith(t => t.Result
.Select(u => (u.Id, $"{u.FirstName} {u.LastName}".Trim()))
.ToList());
}
ViewBag.CurrentUser = currentUser;
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
ViewBag.IsManager = isManager;
ViewBag.NowUtc = DateTime.UtcNow;
ViewBag.CompanyUsers = companyUsers;
ViewBag.AllowMultiplePunches = company?.TimeclockAllowMultiplePunchesPerDay ?? true;
return View();
}
@@ -125,7 +143,8 @@ public class TimeclockController : Controller
userId = e.UserId,
displayName = e.User?.FullName ?? "Unknown",
clockInTime = e.ClockInTime,
entryId = e.Id
entryId = e.Id,
entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch
});
return Json(result);
@@ -294,6 +313,130 @@ public class TimeclockController : Controller
return Json(new { success = true });
}
/// <summary>
/// Closes the current open Work entry and opens a Break or Lunch entry.
/// The caller must have an open Work segment; calling while already on a break returns 400.
/// Multi-punches-per-day setting is intentionally bypassed — taking a break is inherently
/// a second punch in the same day and should always be allowed.
/// </summary>
[HttpPost]
public async Task<IActionResult> GoOnBreak([FromBody] GoOnBreakRequest request)
{
if (request.BreakType is not (ClockEntryType.Break or ClockEntryType.Lunch))
return BadRequest(new { message = "Invalid break type." });
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null)
return BadRequest(new { message = "You are not currently clocked in." });
if (openEntry.EntryType != ClockEntryType.Work)
return BadRequest(new { message = "You are already on a break." });
var now = DateTime.UtcNow;
// Close the work segment
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
// Open the break/lunch segment
var breakEntry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = now,
EntryType = request.BreakType
};
await _unitOfWork.EmployeeClockEntries.AddAsync(breakEntry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} started {BreakType} (entry {EntryId})", userId, request.BreakType, breakEntry.Id);
return Json(new { success = true, entryId = breakEntry.Id });
}
/// <summary>
/// Closes the current open Break/Lunch entry and opens a new Work entry.
/// The caller must have an open Break or Lunch segment; calling while working returns 400.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReturnFromBreak()
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null || openEntry.EntryType == ClockEntryType.Work)
return BadRequest(new { message = "You are not currently on a break." });
var now = DateTime.UtcNow;
// Close the break segment
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
// Open a new work segment
var workEntry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = now,
EntryType = ClockEntryType.Work
};
await _unitOfWork.EmployeeClockEntries.AddAsync(workEntry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} returned from {BreakType} (new entry {EntryId})", userId, openEntry.EntryType, workEntry.Id);
return Json(new { success = true, entryId = workEntry.Id });
}
/// <summary>
/// Manager-only: create a manual time entry for any active employee in the company.
/// Used to record missed punches, paper timesheets, or schedule corrections.
/// ClockInTime and ClockOutTime are expected as UTC ISO strings from the JS client.
/// </summary>
[HttpPost]
public async Task<IActionResult> ManualEntry([FromBody] ManualEntryRequest request)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
// Verify the target user belongs to this company
var targetUser = await _userManager.FindByIdAsync(request.UserId);
if (targetUser == null || targetUser.CompanyId != companyId || !targetUser.IsActive)
return NotFound(new { message = "Employee not found." });
if (request.ClockOutTime.HasValue && request.ClockOutTime.Value <= request.ClockInTime)
return BadRequest(new { message = "Clock-out time must be after clock-in time." });
var entry = new EmployeeClockEntry
{
UserId = request.UserId,
CompanyId = companyId,
ClockInTime = request.ClockInTime,
ClockOutTime = request.ClockOutTime,
Notes = request.Notes?.Trim()
};
if (request.ClockOutTime.HasValue)
entry.HoursWorked = Math.Round(
(decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2);
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Manager {ManagerId} created manual entry for {TargetUserId} (entry {EntryId})",
_userManager.GetUserId(User), request.UserId, entry.Id);
return Json(new { success = true, entryId = entry.Id });
}
// =========================================================================
// KIOSK — device activation
// =========================================================================
@@ -660,6 +803,7 @@ public class TimeclockController : Controller
ClockInTime = e.ClockInTime,
ClockOutTime = e.ClockOutTime,
HoursWorked = e.HoursWorked,
EntryType = e.EntryType,
Notes = e.Notes
};
@@ -1440,25 +1440,40 @@ public static class HelpKnowledgeBase
**Employee Timeclock (/Timeclock):**
Facility-level clock in/out for employees tracks when workers arrive and leave, separate from Job Time Entries which log hours against a specific job.
Multiple punches per day fully supported (clock out for lunch, clock in again, clock out, etc.); each segment tracked separately with its own in/out and hours.
Only constraint: you cannot clock in while already clocked in clock out first.
Each punch creates a segment with a type: Work, Break, or Lunch. Only Work segments count toward paid-hours totals Break and Lunch time is recorded for reference but excluded from all day/week totals and the payroll CSV.
Segment types (ClockEntryType enum): Work=0 (default), Break=1, Lunch=2.
Dashboard (/Timeclock):
- Your clock status card: shows "Clocked In" (with time) or "Clocked Out"; one-click Clock In / Clock Out buttons
- Who's In table: all employees currently clocked in, with elapsed time; refreshes every 60 seconds
- My Recent History: last 14 days of your own punch segments grouped by day with daily totals
- Manager section (CompanyAdmin and Manager roles only): Team History with date-range filter; edit/delete any entry for any employee in the company
- My Clock Status card: shows current state with color-coded badge green "Working", yellow "On Break", blue "At Lunch", or grey "Clocked Out"
- Clock In button: appears when clocked out; starts a new Work segment
- Clock Out button: appears when Working; ends the current Work segment (end of day)
- Break button: appears when Working AND company has "Allow Multiple Punches Per Day" enabled; closes the Work segment and opens a Break segment
- Lunch button: same as Break but opens a Lunch segment
- Return to Work button: appears when on break or lunch; closes the break/lunch segment and opens a new Work segment
- Who's In table: all employees with an open segment, showing their status badge (Working / On Break / At Lunch) and elapsed time; refreshes every 60 seconds
- My Recent History: last 14 days of your own punch segments grouped by day; each row shows a type badge (Work/Break/Lunch); daily totals count Work only
Editing entries: Only Managers and Company Admins can edit or delete clock entries. Workers cannot edit their own entries all corrections must go through a manager.
Manager tools (CompanyAdmin and Manager roles):
- Edit entry: pencil icon on any entry in My Recent History or Team History adjust clock-in, clock-out, notes; hours recalculated on save
- Delete entry: trash icon; soft-deletes the entry
- Manual Entry button (in Team History card header): opens a modal to create a time entry for any active employee in the company; fields: employee dropdown, clock-in datetime, optional clock-out datetime, optional notes; manual entries are always Work type
- Team History: date-range loader showing all employees' segments with type badges and edit/delete controls
Kiosk mode (/Timeclock/Kiosk):
A shared shop tablet page that does not require login. Employees tap their name tile enter 4-digit PIN system automatically clocks in (if out) or clocks out (if in). Confirmation screen shows time, segment hours, and today's running total. Returns to employee grid after 4 seconds.
Activation: Manager navigates to /Timeclock/Kiosk/Activate generates device cookie; kiosk then runs without login. Re-visiting /Kiosk/Activate revokes the old device and re-activates the current one.
A shared shop tablet page that does not require login. Employees tap their name tile enter 4-digit PIN system automatically clocks in (if out) or clocks out (if in). Confirmation screen shows time, segment hours, and today's running total. Returns to employee grid after 4 seconds. Kiosk performs a simple toggle only no Break/Lunch buttons on the kiosk; use the main dashboard for break tracking.
Activation: Manager navigates to /Timeclock/Kiosk/Activate generates device cookie; kiosk then runs without login. Multiple tablets per company supported, each with its own token. Deactivate from Settings Company Timeclock tab.
PIN management: on a user's Edit page (Settings Users Edit User) there is a "Timeclock Kiosk PIN" section. Enter a 4-digit PIN and click "Set PIN" to enable that employee on the kiosk. Click "Clear" to disable them.
PIN management: on a user's Edit page (Settings Users Edit User) there is a "Timeclock Kiosk PIN" section. Enter a 4-digit PIN and click "Set PIN" to enable that employee on the kiosk. Click "Clear" to disable them. Only employees with a PIN appear on the kiosk grid.
Attendance Report (/Reports/Attendance): shows daily punch detail per employee with date-range filter. Each employee card groups segments by day (with day totals) and by ISO week (with weekly subtotal rows). Managers see all employees; workers see their own history only.
CSV Export: "Export CSV (Payroll)" button downloads a flat CSV with one row per punch segment columns: Employee Name, Date, Day of Week, Clock In, Clock Out, Segment Hours, Day Total Hours, Week Total Hours, Notes. Suitable for import into payroll software or handoff to a payroll provider.
Attendance Report (/Reports/Attendance):
- Defaults to the current ISO week; toggle between Week and Month view using the mode buttons
- Week view: dropdown of the last 12 weeks (labelled "This Week", "Last Week", then date ranges); weekly subtotal rows hidden (redundant for a single-week view)
- Month view: dropdown of the last 12 months; weekly subtotal rows shown inside each employee card
- Dropdown auto-applies on change no separate submit button needed
- Each segment row has a type badge (Work/Break/Lunch); Break and Lunch hours shown in muted text and excluded from all totals
- Employee total badge and day totals count Work segments only
CSV Export: "Export CSV (Payroll)" downloads a flat file for the selected period one row per segment; columns: Employee Name, Date, Day of Week, Type, Clock In, Clock Out, Segment Hours, Day Total Hours (Work rows only), Week Total Hours (Work rows only), Notes. Filename includes the period label.
Help article: Help Timeclock
""";
+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>
+99 -10
View File
@@ -9,16 +9,19 @@ var Timeclock = (function () {
scheduleWhosInRefresh();
}
/* ── Clock In / Out ────────────────────────────────────────────────────── */
/* ── Clock In / Out / Break / Lunch / Return ───────────────────────────── */
function bindClockButtons() {
var btnIn = document.getElementById('btn-clock-in');
var btnOut = document.getElementById('btn-clock-out');
var btnIn = document.getElementById('btn-clock-in');
var btnOut = document.getElementById('btn-clock-out');
var btnBreak = document.getElementById('btn-break');
var btnLunch = document.getElementById('btn-lunch');
var btnReturn = document.getElementById('btn-return-from-break');
if (btnIn) {
btnIn.addEventListener('click', function () {
btnIn.disabled = true;
postJson('/Timeclock/ClockIn', {})
.then(function (r) { location.reload(); })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnIn.disabled = false;
@@ -31,13 +34,40 @@ var Timeclock = (function () {
btnOut.disabled = true;
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
postJson('/Timeclock/ClockOut', { entryId: entryId })
.then(function (r) { location.reload(); })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnOut.disabled = false;
});
});
}
// Break button (type 1) and Lunch button (type 2) share the same handler
[btnBreak, btnLunch].forEach(function (btn) {
if (!btn) return;
btn.addEventListener('click', function () {
btn.disabled = true;
var breakType = parseInt(btn.getAttribute('data-break-type'));
postJson('/Timeclock/GoOnBreak', { breakType: breakType })
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btn.disabled = false;
});
});
});
if (btnReturn) {
btnReturn.addEventListener('click', function () {
btnReturn.disabled = true;
postJson('/Timeclock/ReturnFromBreak', {})
.then(function () { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnReturn.disabled = false;
});
});
}
}
/* ── Who's In refresh every 60 s ──────────────────────────────────────── */
@@ -58,7 +88,7 @@ var Timeclock = (function () {
});
}
/* ── Manager: edit / delete ────────────────────────────────────────────── */
/* ── Manager: edit / delete / manual entry ────────────────────────────── */
function bindManagerEvents() {
if (!_cfg.isManager) return;
@@ -72,6 +102,21 @@ var Timeclock = (function () {
var btnLoad = document.getElementById('btn-manager-load');
if (btnLoad) btnLoad.addEventListener('click', loadManagerHistory);
var btnSaveManual = document.getElementById('btn-save-manual-entry');
if (btnSaveManual) btnSaveManual.addEventListener('click', saveManualEntry);
// Pre-fill clock-in to "now" each time the modal opens
var manualModal = document.getElementById('manualEntryModal');
if (manualModal) {
manualModal.addEventListener('show.bs.modal', function () {
document.getElementById('manual-clock-in').value = toDatetimeLocal(new Date().toISOString());
document.getElementById('manual-clock-out').value = '';
document.getElementById('manual-notes').value = '';
document.getElementById('manual-employee').value = '';
setManualFeedback('', '');
});
}
}
function openEditModal(btn) {
@@ -109,6 +154,44 @@ var Timeclock = (function () {
.catch(function (err) { alert('Error: ' + err); });
}
function saveManualEntry() {
var userId = document.getElementById('manual-employee').value;
var inVal = document.getElementById('manual-clock-in').value;
var outVal = document.getElementById('manual-clock-out').value;
var notes = document.getElementById('manual-notes').value;
if (!userId) { setManualFeedback('Please select an employee.', 'danger'); return; }
if (!inVal) { setManualFeedback('Clock-in time is required.', 'danger'); return; }
if (outVal && new Date(outVal) <= new Date(inVal)) {
setManualFeedback('Clock-out must be after clock-in.', 'danger');
return;
}
var btn = document.getElementById('btn-save-manual-entry');
btn.disabled = true;
postJson('/Timeclock/ManualEntry', {
userId: userId,
clockInTime: new Date(inVal).toISOString(),
clockOutTime: outVal ? new Date(outVal).toISOString() : null,
notes: notes || null
}).then(function () {
bootstrap.Modal.getInstance(document.getElementById('manualEntryModal')).hide();
location.reload();
}).catch(function (err) {
setManualFeedback(err || 'Failed to save entry.', 'danger');
btn.disabled = false;
});
}
function setManualFeedback(msg, type) {
var el = document.getElementById('manual-entry-feedback');
if (!el) return;
if (!msg) { el.className = 'd-none'; el.textContent = ''; return; }
el.className = 'alert alert-' + type + ' alert-permanent py-2';
el.textContent = msg;
}
function loadManagerHistory() {
var from = document.getElementById('manager-from').value;
var to = document.getElementById('manager-to').value;
@@ -128,12 +211,18 @@ var Timeclock = (function () {
return;
}
var TYPE_LABELS = ['<span class="badge bg-success">Work</span>',
'<span class="badge bg-warning text-dark">Break</span>',
'<span class="badge bg-info text-dark">Lunch</span>'];
var rows = entries.map(function (e) {
var inTime = new Date(e.clockInTime).toLocaleString();
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '&mdash;';
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '&mdash;';
var inTime = new Date(e.clockInTime).toLocaleString();
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '&mdash;';
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '&mdash;';
var typeBadge = TYPE_LABELS[e.entryType] || TYPE_LABELS[0];
return '<tr>' +
'<td>' + escHtml(e.userDisplayName) + '</td>' +
'<td>' + typeBadge + '</td>' +
'<td>' + inTime + '</td>' +
'<td>' + outTime + '</td>' +
'<td>' + hours + '</td>' +
@@ -143,7 +232,7 @@ var Timeclock = (function () {
container.innerHTML = '<table class="table table-hover table-sm mb-0">' +
'<thead class="table-light"><tr>' +
'<th>Employee</th><th>Clock In</th><th>Clock Out</th><th>Hours</th><th>Notes</th>' +
'<th>Employee</th><th>Type</th><th>Clock In</th><th>Clock Out</th><th>Hours</th><th>Notes</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}