Add timeclock break/lunch tracking, manual entries, and attendance period picker
- ClockEntryType enum (Work/Break/Lunch) on EmployeeClockEntry; default 0 = Work so all existing entries are unaffected - Migration AddClockEntryType applied - Break and Lunch buttons on clock status card (only when AllowMultiplePunchesPerDay is enabled); GoOnBreak closes current Work segment and opens Break/Lunch segment - Return to Work button when on break/lunch; closes break segment, opens new Work - Status badges on clock card and Who'\''s In grid: Working / On Break / At Lunch - Break/Lunch hours excluded from all day totals, week totals, metrics, and CSV - Manager: Manual Entry modal to create a time entry for any company employee - Attendance report defaults to current ISO week; Week/Month mode toggle with auto-submitting dropdowns (last 12 weeks or months); period label shown inline - Attendance CSV: Type column added; day/week totals blank on Break/Lunch rows; filename uses period label - Week subtotal rows suppressed in single-week view (shown in month view only) - Help article and AI knowledge base updated for all new features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,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
|
||||
}
|
||||
+10921
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
|
||||
""";
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
<p>
|
||||
The Timeclock module tracks facility-level employee attendance — 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 — 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 › 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> — click the green “Clock In” button when you arrive. Your start time is recorded immediately as a Work segment.</li>
|
||||
<li><strong>Clock Out</strong> — click the red “Clock Out” 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> — click the green “Clock In” button. Your start time is recorded immediately.</li>
|
||||
<li><strong>Clock Out</strong> — click the red “Clock Out” button. Hours worked for that segment are calculated and saved.</li>
|
||||
<li><strong>Multiple segments per day</strong> — 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> — 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> — 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 —
|
||||
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 — 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’s In
|
||||
</h2>
|
||||
<p>
|
||||
The “Who’s In” card on the dashboard shows everyone currently clocked in, with
|
||||
their start time and elapsed hours. It refreshes automatically every 60 seconds —
|
||||
no manual reload needed.
|
||||
The <strong>Who’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> — actively on the clock</li>
|
||||
<li><span class="badge bg-warning text-dark">Break</span> — on a short break</li>
|
||||
<li><span class="badge bg-info text-dark">Lunch</span> — 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 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 — 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’s running total, then returns to the
|
||||
employee grid after 4 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’s running total,
|
||||
then returns to the employee grid after 4 seconds.
|
||||
</p>
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
The kiosk performs a simple toggle — 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 — each tablet gets its own token.
|
||||
To deactivate a tablet, go to <strong>Settings › Company › 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 › Users</strong> and click the employee’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 › Users</strong> and open the employee’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 — 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> — 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> — click the trash icon. The entry is soft-deleted
|
||||
and excluded from all totals and reports.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Team History</strong> — 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 & CSV Export
|
||||
</h2>
|
||||
<p>
|
||||
Go to <strong>Reports › Attendance</strong> for a date-range attendance summary.
|
||||
Each employee’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 › 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> — choose any of the last 12 weeks from the dropdown.
|
||||
“This Week” and “Last Week” 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> — 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 — no separate “Filter” 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> — Work, Break, or Lunch</li>
|
||||
<li>Clock In, Clock Out, Segment Hours</li>
|
||||
<li>Day Total Hours, Week Total Hours — 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 & Out</a></li>
|
||||
<li><a href="#clocking-in-out" class="text-decoration-none">Clocking In, Out & Breaks</a></li>
|
||||
<li><a href="#whos-in" class="text-decoration-none">Who’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") ?? "—")</td>
|
||||
<td class="text-end">
|
||||
@if (seg.EntryType != ClockEntryType.Work)
|
||||
{
|
||||
<span class="text-muted">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "—")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "—")
|
||||
}
|
||||
</td>
|
||||
@if (i == 0)
|
||||
{
|
||||
<td class="text-end fw-semibold" rowspan="@segs.Count">
|
||||
@@ -117,12 +214,16 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
<tr class="table-secondary">
|
||||
<td colspan="4" class="text-end text-muted small fst-italic">
|
||||
Week of @weekStart.ToString("M/d") – @weekEnd.ToString("M/d")
|
||||
</td>
|
||||
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
|
||||
</tr>
|
||||
@* Show week subtotal row only when viewing a month (multiple weeks visible) *@
|
||||
@if (!isWeek)
|
||||
{
|
||||
<tr class="table-secondary">
|
||||
<td colspan="5" class="text-end text-muted small fst-italic">
|
||||
Week of @weekStart.ToString("M/d") – @weekEnd.ToString("M/d")
|
||||
</td>
|
||||
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -130,3 +231,19 @@ else
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Toggle between week and month forms when the mode buttons are clicked
|
||||
document.getElementById('btn-mode-week').addEventListener('click', function () {
|
||||
document.getElementById('form-week').classList.remove('d-none');
|
||||
document.getElementById('form-month').classList.add('d-none');
|
||||
document.getElementById('form-week').submit();
|
||||
});
|
||||
document.getElementById('btn-mode-month').addEventListener('click', function () {
|
||||
document.getElementById('form-month').classList.remove('d-none');
|
||||
document.getElementById('form-week').classList.add('d-none');
|
||||
document.getElementById('form-month').submit();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() : '—';
|
||||
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
||||
var inTime = new Date(e.clockInTime).toLocaleString();
|
||||
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '—';
|
||||
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
||||
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>';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user