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;
|
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||||
|
|
||||||
public class EmployeeClockEntryDto
|
public class EmployeeClockEntryDto
|
||||||
@@ -8,6 +10,7 @@ public class EmployeeClockEntryDto
|
|||||||
public DateTime ClockInTime { get; set; }
|
public DateTime ClockInTime { get; set; }
|
||||||
public DateTime? ClockOutTime { get; set; }
|
public DateTime? ClockOutTime { get; set; }
|
||||||
public decimal? HoursWorked { get; set; }
|
public decimal? HoursWorked { get; set; }
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool IsOpen => ClockOutTime == null;
|
public bool IsOpen => ClockOutTime == null;
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,25 @@ public class EditClockEntryRequest
|
|||||||
public string? Notes { get; set; }
|
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>
|
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||||
public class KioskEmployeeDto
|
public class KioskEmployeeDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
namespace PowderCoating.Core.Entities;
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||||
public decimal? HoursWorked { get; set; }
|
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 string? Notes { get; set; }
|
||||||
|
|
||||||
public virtual ApplicationUser User { get; set; } = null!;
|
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")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("EntryType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("HoursWorked")
|
b.Property<decimal?>("HoursWorked")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -6865,7 +6868,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6876,7 +6879,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6887,7 +6890,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
|
|||||||
@@ -2484,7 +2484,7 @@ public class ReportsController : Controller
|
|||||||
/// Managers can see all employees; Workers see their own history only.
|
/// Managers can see all employees; Workers see their own history only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[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);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser == null) return Forbid();
|
if (currentUser == null) return Forbid();
|
||||||
@@ -2492,13 +2492,12 @@ public class ReportsController : Controller
|
|||||||
var companyId = currentUser.CompanyId;
|
var companyId = currentUser.CompanyId;
|
||||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||||
|
|
||||||
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
|
var period = ResolveAttendancePeriod(mode, week, month);
|
||||||
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
|
|
||||||
|
|
||||||
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||||
e => e.CompanyId == companyId
|
e => e.CompanyId == companyId
|
||||||
&& e.ClockInTime >= start
|
&& e.ClockInTime >= period.Start
|
||||||
&& e.ClockInTime < end
|
&& e.ClockInTime < period.End
|
||||||
&& (isManager || e.UserId == currentUser.Id),
|
&& (isManager || e.UserId == currentUser.Id),
|
||||||
false,
|
false,
|
||||||
e => e.User);
|
e => e.User);
|
||||||
@@ -2509,21 +2508,21 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
UserId = g.Key,
|
UserId = g.Key,
|
||||||
DisplayName = g.First().User?.FullName ?? "Unknown",
|
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)
|
Days = g.GroupBy(e => e.ClockInTime.Date)
|
||||||
.OrderByDescending(d => d.Key)
|
.OrderByDescending(d => d.Key)
|
||||||
.Select(d => new AttendanceDayRow
|
.Select(d => new AttendanceDayRow
|
||||||
{
|
{
|
||||||
Date = d.Key,
|
Date = d.Key,
|
||||||
DayTotal = Math.Round(d.Sum(e => e.HoursWorked ?? 0), 2),
|
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()
|
Segments = d.OrderBy(e => e.ClockInTime).ToList()
|
||||||
}).ToList()
|
}).ToList()
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.DisplayName)
|
.OrderBy(r => r.DisplayName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.From = start;
|
ViewBag.Period = period;
|
||||||
ViewBag.To = end.AddDays(-1);
|
|
||||||
ViewBag.IsManager = isManager;
|
ViewBag.IsManager = isManager;
|
||||||
return View(grouped);
|
return View(grouped);
|
||||||
}
|
}
|
||||||
@@ -2535,7 +2534,7 @@ public class ReportsController : Controller
|
|||||||
/// every row so the file is fully self-contained in flat-file format.
|
/// every row so the file is fully self-contained in flat-file format.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[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);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
if (currentUser == null) return Forbid();
|
if (currentUser == null) return Forbid();
|
||||||
@@ -2543,8 +2542,9 @@ public class ReportsController : Controller
|
|||||||
var companyId = currentUser.CompanyId;
|
var companyId = currentUser.CompanyId;
|
||||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||||
|
|
||||||
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
|
var period = ResolveAttendancePeriod(mode, week, month);
|
||||||
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
|
var start = period.Start;
|
||||||
|
var end = period.End;
|
||||||
|
|
||||||
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||||
e => e.CompanyId == companyId
|
e => e.CompanyId == companyId
|
||||||
@@ -2560,8 +2560,10 @@ public class ReportsController : Controller
|
|||||||
.ThenBy(e => e.ClockInTime)
|
.ThenBy(e => e.ClockInTime)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Pre-compute day totals and week totals per user
|
// Day/week totals count only Work segments (Break/Lunch are unpaid)
|
||||||
var dayTotals = entries
|
var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();
|
||||||
|
|
||||||
|
var dayTotals = workEntries
|
||||||
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
|
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
|
||||||
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||||
|
|
||||||
@@ -2569,18 +2571,19 @@ public class ReportsController : Controller
|
|||||||
static int IsoWeek(DateTime d) =>
|
static int IsoWeek(DateTime d) =>
|
||||||
System.Globalization.ISOWeek.GetWeekOfYear(d);
|
System.Globalization.ISOWeek.GetWeekOfYear(d);
|
||||||
|
|
||||||
var weekTotals = entries
|
var weekTotals = workEntries
|
||||||
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
|
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
|
||||||
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
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)
|
foreach (var entry in rows)
|
||||||
{
|
{
|
||||||
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
|
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
|
||||||
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
|
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
|
||||||
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
|
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 clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
|
||||||
var clockOut = entry.ClockOutTime.HasValue
|
var clockOut = entry.ClockOutTime.HasValue
|
||||||
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
|
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
|
||||||
@@ -2588,18 +2591,21 @@ public class ReportsController : Controller
|
|||||||
var segHrs = entry.HoursWorked.HasValue
|
var segHrs = entry.HoursWorked.HasValue
|
||||||
? entry.HoursWorked.Value.ToString("F2")
|
? 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 dayKey = (entry.UserId, entry.ClockInTime.Date);
|
||||||
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
|
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
|
||||||
var dayTotal = dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
|
var dayTotal = isWork && dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
|
||||||
var wkTotal = weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
|
var wkTotal = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
|
||||||
var notes = CsvEscape(entry.Notes ?? "");
|
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 company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
|
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
|
||||||
var filename = $"{safeName}_Attendance_{start:yyyyMMdd}_to_{end.AddDays(-1):yyyyMMdd}.csv";
|
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);
|
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
|
||||||
}
|
}
|
||||||
@@ -2611,6 +2617,89 @@ public class ReportsController : Controller
|
|||||||
return value;
|
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>
|
/// <summary>
|
||||||
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
|
/// 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
|
/// 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();
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.DTOs.Timeclock;
|
using PowderCoating.Application.DTOs.Timeclock;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
@@ -55,6 +56,7 @@ public class TimeclockController : Controller
|
|||||||
if (currentUser == null) return Forbid();
|
if (currentUser == null) return Forbid();
|
||||||
|
|
||||||
var companyId = currentUser.CompanyId;
|
var companyId = currentUser.CompanyId;
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
|
||||||
// Current user's open entry (null = clocked out)
|
// Current user's open entry (null = clocked out)
|
||||||
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||||
@@ -73,12 +75,28 @@ public class TimeclockController : Controller
|
|||||||
|
|
||||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||||
|
|
||||||
|
// 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.CurrentUser = currentUser;
|
||||||
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
|
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
|
||||||
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
|
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
|
||||||
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
|
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
|
||||||
ViewBag.IsManager = isManager;
|
ViewBag.IsManager = isManager;
|
||||||
ViewBag.NowUtc = DateTime.UtcNow;
|
ViewBag.NowUtc = DateTime.UtcNow;
|
||||||
|
ViewBag.CompanyUsers = companyUsers;
|
||||||
|
ViewBag.AllowMultiplePunches = company?.TimeclockAllowMultiplePunchesPerDay ?? true;
|
||||||
|
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
@@ -125,7 +143,8 @@ public class TimeclockController : Controller
|
|||||||
userId = e.UserId,
|
userId = e.UserId,
|
||||||
displayName = e.User?.FullName ?? "Unknown",
|
displayName = e.User?.FullName ?? "Unknown",
|
||||||
clockInTime = e.ClockInTime,
|
clockInTime = e.ClockInTime,
|
||||||
entryId = e.Id
|
entryId = e.Id,
|
||||||
|
entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch
|
||||||
});
|
});
|
||||||
|
|
||||||
return Json(result);
|
return Json(result);
|
||||||
@@ -294,6 +313,130 @@ public class TimeclockController : Controller
|
|||||||
return Json(new { success = true });
|
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
|
// KIOSK — device activation
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -660,6 +803,7 @@ public class TimeclockController : Controller
|
|||||||
ClockInTime = e.ClockInTime,
|
ClockInTime = e.ClockInTime,
|
||||||
ClockOutTime = e.ClockOutTime,
|
ClockOutTime = e.ClockOutTime,
|
||||||
HoursWorked = e.HoursWorked,
|
HoursWorked = e.HoursWorked,
|
||||||
|
EntryType = e.EntryType,
|
||||||
Notes = e.Notes
|
Notes = e.Notes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1440,25 +1440,40 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Employee Timeclock (/Timeclock):**
|
**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.
|
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.
|
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.
|
||||||
Only constraint: you cannot clock in while already clocked in — clock out first.
|
|
||||||
|
Segment types (ClockEntryType enum): Work=0 (default), Break=1, Lunch=2.
|
||||||
|
|
||||||
Dashboard (/Timeclock):
|
Dashboard (/Timeclock):
|
||||||
- Your clock status card: shows "Clocked In" (with time) or "Clocked Out"; one-click Clock In / Clock Out buttons
|
- My Clock Status card: shows current state with color-coded badge — green "Working", yellow "On Break", blue "At Lunch", or grey "Clocked Out"
|
||||||
- Who's In table: all employees currently clocked in, with elapsed time; refreshes every 60 seconds
|
- Clock In button: appears when clocked out; starts a new Work segment
|
||||||
- My Recent History: last 14 days of your own punch segments grouped by day with daily totals
|
- Clock Out button: appears when Working; ends the current Work segment (end of day)
|
||||||
- Manager section (CompanyAdmin and Manager roles only): Team History with date-range filter; edit/delete any entry for any employee in the company
|
- 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):
|
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.
|
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. Re-visiting /Kiosk/Activate revokes the old device and re-activates the current one.
|
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.
|
Attendance Report (/Reports/Attendance):
|
||||||
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.
|
- 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
|
Help article: Help → Timeclock
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
<p>
|
<p>
|
||||||
The Timeclock module tracks facility-level employee attendance — when workers arrive and
|
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
|
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
|
against a specific job). Timeclock is about payroll and attendance; Job Time Entries are about job costing.
|
||||||
job costing.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Multiple clock-in/clock-out pairs per day are fully supported. Employees can clock out for
|
Each punch creates a <strong>segment</strong> with a type: <strong>Work</strong>, <strong>Break</strong>,
|
||||||
lunch, clock back in, clock out for a break, and so on. Each segment is recorded separately
|
or <strong>Lunch</strong>. Only Work segments count toward paid-hours totals — break and lunch
|
||||||
with its own in/out times and duration. Daily and weekly totals are computed automatically.
|
time appears in the attendance history for reference but is excluded from day totals, weekly totals,
|
||||||
|
and the payroll CSV export.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Find Timeclock under <strong>Shop Floor › Timeclock</strong> in the left sidebar.
|
Find Timeclock under <strong>Shop Floor › Timeclock</strong> in the left sidebar.
|
||||||
@@ -37,20 +37,44 @@
|
|||||||
|
|
||||||
<section id="clocking-in-out" class="mb-5">
|
<section id="clocking-in-out" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<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>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
From the Timeclock dashboard, your current status card shows whether you are clocked in or out
|
Your <strong>My Clock Status</strong> card on the Timeclock dashboard shows your current state
|
||||||
and how long the current segment has been running.
|
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>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Clock In</strong> — click the green “Clock In” button. Your start time is recorded immediately.</li>
|
<li>
|
||||||
<li><strong>Clock Out</strong> — click the red “Clock Out” button. Hours worked for that segment are calculated and saved.</li>
|
<strong><i class="bi bi-cup-hot"></i> Break</strong> — closes your current Work segment
|
||||||
<li><strong>Multiple segments per day</strong> — after clocking out, just clock in again when you return. Each segment is stored separately.</li>
|
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>
|
</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">
|
<div class="alert alert-info alert-permanent">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -59,9 +83,17 @@
|
|||||||
<i class="bi bi-people-fill text-primary me-2"></i>Who’s In
|
<i class="bi bi-people-fill text-primary me-2"></i>Who’s In
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
The “Who’s In” card on the dashboard shows everyone currently clocked in, with
|
The <strong>Who’s In</strong> card shows everyone with an open segment right now, along
|
||||||
their start time and elapsed hours. It refreshes automatically every 60 seconds —
|
with their current status:
|
||||||
no manual reload needed.
|
</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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -72,10 +104,15 @@
|
|||||||
<p>
|
<p>
|
||||||
The Timeclock Kiosk is a tablet-friendly page designed to live on a shared device in the shop.
|
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.
|
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
|
The system automatically clocks them in (if currently out) or out (if currently in).
|
||||||
screen shows the time, segment duration, and today’s running total, then returns to the
|
A confirmation screen shows the time, segment duration, and today’s running total,
|
||||||
employee grid after 4 seconds.
|
then returns to the employee grid after 4 seconds.
|
||||||
</p>
|
</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>
|
<h5 class="fw-semibold mt-3">Activating the kiosk</h5>
|
||||||
<ol>
|
<ol>
|
||||||
<li>On the tablet, log in as a Manager or Company Admin.</li>
|
<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>
|
<li>From now on, <code>/Timeclock/Kiosk</code> works without login on that device.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
If the tablet is lost or replaced, re-visit <strong>Activate Kiosk</strong> on a new device. The
|
Multiple tablets per company are supported — each tablet gets its own token.
|
||||||
old token is revoked automatically. To deactivate without re-activating, ask a Manager to clear the token from Company Settings.
|
To deactivate a tablet, go to <strong>Settings › Company › Timeclock</strong>
|
||||||
|
and click <strong>Deactivate</strong> next to the device.
|
||||||
</p>
|
</p>
|
||||||
<h5 class="fw-semibold mt-3">Setting employee PINs</h5>
|
<h5 class="fw-semibold mt-3">Setting employee PINs</h5>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Go to <strong>Settings › Users</strong> and click the employee’s name.</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 at the bottom of the Edit User 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>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>
|
<li>To disable an employee on the kiosk, click <strong>Clear</strong>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p>
|
<p>
|
||||||
Only employees with a PIN set will appear on the kiosk employee grid. Employees without a PIN
|
Only employees with a PIN set will appear on the kiosk employee grid.
|
||||||
cannot use the kiosk tablet.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -108,21 +145,38 @@
|
|||||||
Managers and Company Admins have additional controls on the Timeclock dashboard.
|
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.
|
Workers cannot edit or delete their own entries — all corrections must go through a Manager or Company Admin.
|
||||||
</p>
|
</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>
|
<ul>
|
||||||
<li>
|
<li>You can create an <em>open</em> entry (no clock-out) if the employee is still on the clock.</li>
|
||||||
<strong>Edit an entry</strong> — click the pencil icon next to any entry in the
|
<li>Manual entries are always created as Work segments.</li>
|
||||||
My Recent History table or the Team History table. Adjust clock-in time, clock-out time,
|
<li>Only employees in your company appear in the employee dropdown.</li>
|
||||||
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>
|
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<section id="reports" class="mb-5">
|
<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
|
<i class="bi bi-bar-chart-line text-info me-2"></i>Attendance Report & CSV Export
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Go to <strong>Reports › Attendance</strong> for a date-range attendance summary.
|
Go to <strong>Reports › Attendance</strong> for a structured attendance summary.
|
||||||
Each employee’s card shows every daily punch segment and a day total, plus
|
Managers see all employees; workers see only their own history.
|
||||||
a weekly subtotal row at the bottom of each ISO week. Managers see all employees;
|
|
||||||
workers see only their own history.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h5 class="fw-semibold mt-3">Choosing a period</h5>
|
||||||
<p>
|
<p>
|
||||||
Click <strong>Export CSV (Payroll)</strong> to download a flat CSV file of the same
|
The report defaults to the <strong>current week</strong>. Use the toggle and dropdown at
|
||||||
date range. Each row is one clock segment and includes:
|
the top to switch between views:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Employee Name</li>
|
<li>
|
||||||
<li>Date and Day of Week</li>
|
<strong>Week</strong> — choose any of the last 12 weeks from the dropdown.
|
||||||
<li>Clock In and Clock Out times</li>
|
“This Week” and “Last Week” are pre-labelled for quick access.
|
||||||
<li>Segment Hours, Day Total Hours, and Week Total Hours</li>
|
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>
|
<li>Notes</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
The CSV is designed to be imported directly into payroll software or handed off to a
|
The CSV is self-contained (one row per segment, no merged cells) and imports directly into
|
||||||
payroll provider. Every row is self-contained (no merged cells or subtotal-only rows)
|
Excel, Google Sheets, and most payroll software. The filename includes the period label
|
||||||
so it works with Excel, Google Sheets, and most payroll import wizards.
|
(e.g. <code>Acme_Attendance_Week_of_May_26-Jun_1_2026.csv</code>).
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -161,7 +243,7 @@
|
|||||||
<h6 class="fw-bold mb-3">On This Page</h6>
|
<h6 class="fw-bold mb-3">On This Page</h6>
|
||||||
<ul class="list-unstyled small">
|
<ul class="list-unstyled small">
|
||||||
<li><a href="#overview" class="text-decoration-none">Overview</a></li>
|
<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="#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="#kiosk" class="text-decoration-none">Shop Floor Kiosk</a></li>
|
||||||
<li><a href="#manager-tools" class="text-decoration-none">Manager Tools</a></li>
|
<li><a href="#manager-tools" class="text-decoration-none">Manager Tools</a></li>
|
||||||
|
|||||||
@@ -1,41 +1,112 @@
|
|||||||
|
@using PowderCoating.Core.Enums
|
||||||
|
@using System.Globalization
|
||||||
@model List<AttendanceEmployeeRow>
|
@model List<AttendanceEmployeeRow>
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Attendance Report";
|
ViewData["Title"] = "Attendance Report";
|
||||||
ViewData["PageIcon"] = "bi-clock-history";
|
ViewData["PageIcon"] = "bi-clock-history";
|
||||||
var from = (DateTime)ViewBag.From;
|
var period = (AttendancePeriod)ViewBag.Period;
|
||||||
var to = (DateTime)ViewBag.To;
|
|
||||||
var isManager = (bool)(ViewBag.IsManager ?? false);
|
var isManager = (bool)(ViewBag.IsManager ?? false);
|
||||||
|
var isWeek = period.Mode == "week";
|
||||||
|
|
||||||
// ISO week number helper
|
// ISO week number helper (used for week subtotal grouping below)
|
||||||
int IsoWeek(DateTime d) => System.Globalization.ISOWeek.GetWeekOfYear(d);
|
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">
|
@* ── Period picker ────────────────────────────────────────────────────────── *@
|
||||||
<div class="col">
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
|
||||||
<form method="get" class="d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
<label class="fw-semibold">From</label>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<input type="date" name="from" class="form-control form-control-sm" style="width:150px" value="@from.ToString("yyyy-MM-dd")" />
|
@* Mode toggle *@
|
||||||
<label class="fw-semibold">To</label>
|
<div class="btn-group btn-group-sm" role="group" id="attendance-mode-group">
|
||||||
<input type="date" name="to" class="form-control form-control-sm" style="width:150px" value="@to.ToString("yyyy-MM-dd")" />
|
<button type="button" id="btn-mode-week"
|
||||||
<button type="submit" class="btn btn-sm btn-primary">
|
class="btn @(isWeek ? "btn-primary" : "btn-outline-primary")">
|
||||||
<i class="bi bi-search me-1"></i>Filter
|
<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>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
|
||||||
|
@* 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>
|
||||||
|
|
||||||
|
@* Export CSV — passes same mode/period params *@
|
||||||
<a asp-action="AttendanceCsv"
|
<a asp-action="AttendanceCsv"
|
||||||
asp-route-from="@from.ToString("yyyy-MM-dd")"
|
asp-route-mode="@period.Mode"
|
||||||
asp-route-to="@to.ToString("yyyy-MM-dd")"
|
asp-route-week="@(isWeek ? period.WeekValue : null)"
|
||||||
|
asp-route-month="@(!isWeek ? period.MonthValue : null)"
|
||||||
class="btn btn-sm btn-outline-success">
|
class="btn btn-sm btn-outline-success">
|
||||||
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export CSV (Payroll)
|
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export CSV (Payroll)
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!Model.Any())
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<div class="alert alert-info alert-permanent">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
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))
|
@await Html.PartialAsync("_Metric", (Label: "TOTAL HOURS", Value: Model.Sum(r => r.TotalHours).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
|
||||||
</div>
|
</div>
|
||||||
<div class="pcl-metric-strip-cell">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,6 +146,7 @@ else
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Clock In</th>
|
<th>Clock In</th>
|
||||||
<th>Clock Out</th>
|
<th>Clock Out</th>
|
||||||
<th class="text-end">Segment Hrs</th>
|
<th class="text-end">Segment Hrs</th>
|
||||||
@@ -82,7 +157,6 @@ else
|
|||||||
@foreach (var wg in weekGroups)
|
@foreach (var wg in weekGroups)
|
||||||
{
|
{
|
||||||
var weekTotal = wg.Sum(d => d.DayTotal);
|
var weekTotal = wg.Sum(d => d.DayTotal);
|
||||||
// First day of week group (chronologically earliest) for label
|
|
||||||
var weekStart = wg.Min(d => d.Date);
|
var weekStart = wg.Min(d => d.Date);
|
||||||
var weekEnd = wg.Max(d => d.Date);
|
var weekEnd = wg.Max(d => d.Date);
|
||||||
|
|
||||||
@@ -99,6 +173,20 @@ else
|
|||||||
@day.Date.ToString("ddd M/d/yy")
|
@day.Date.ToString("ddd M/d/yy")
|
||||||
</td>
|
</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>@seg.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (seg.ClockOutTime.HasValue)
|
@if (seg.ClockOutTime.HasValue)
|
||||||
@@ -106,7 +194,16 @@ else
|
|||||||
else
|
else
|
||||||
{ <span class="badge bg-success">In Progress</span> }
|
{ <span class="badge bg-success">In Progress</span> }
|
||||||
</td>
|
</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)
|
@if (i == 0)
|
||||||
{
|
{
|
||||||
<td class="text-end fw-semibold" rowspan="@segs.Count">
|
<td class="text-end fw-semibold" rowspan="@segs.Count">
|
||||||
@@ -117,16 +214,36 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Show week subtotal row only when viewing a month (multiple weeks visible) *@
|
||||||
|
@if (!isWeek)
|
||||||
|
{
|
||||||
<tr class="table-secondary">
|
<tr class="table-secondary">
|
||||||
<td colspan="4" class="text-end text-muted small fst-italic">
|
<td colspan="5" class="text-end text-muted small fst-italic">
|
||||||
Week of @weekStart.ToString("M/d") – @weekEnd.ToString("M/d")
|
Week of @weekStart.ToString("M/d") – @weekEnd.ToString("M/d")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
|
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,4 +1,5 @@
|
|||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
|
@using PowderCoating.Core.Enums
|
||||||
@using PowderCoating.Application.DTOs.Timeclock
|
@using PowderCoating.Application.DTOs.Timeclock
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Timeclock";
|
ViewData["Title"] = "Timeclock";
|
||||||
@@ -10,6 +11,10 @@
|
|||||||
var isManager = (bool)(ViewBag.IsManager ?? false);
|
var isManager = (bool)(ViewBag.IsManager ?? false);
|
||||||
var nowUtc = (DateTime)(ViewBag.NowUtc ?? DateTime.UtcNow);
|
var nowUtc = (DateTime)(ViewBag.NowUtc ?? DateTime.UtcNow);
|
||||||
var isClockedIn = openEntry != null;
|
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 ───────────────────────────────────────────────────────────── *@
|
@* ── Metric strip ───────────────────────────────────────────────────────────── *@
|
||||||
@@ -20,7 +25,8 @@
|
|||||||
<div class="pcl-metric-strip-cell">
|
<div class="pcl-metric-strip-cell">
|
||||||
@{
|
@{
|
||||||
var todayStart = nowUtc.Date;
|
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);
|
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))
|
@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">
|
<div class="pcl-metric-strip-cell">
|
||||||
@{
|
@{
|
||||||
var weekStart = nowUtc.Date.AddDays(-(int)nowUtc.DayOfWeek);
|
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);
|
.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))
|
@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>
|
||||||
<div class="card-body text-center py-4">
|
<div class="card-body text-center py-4">
|
||||||
<div id="clock-status-display">
|
<div id="clock-status-display">
|
||||||
@if (isClockedIn)
|
@if (!isClockedIn)
|
||||||
{
|
|
||||||
<div class="mb-3">
|
|
||||||
<span class="badge bg-success fs-6 px-3 py-2">
|
|
||||||
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
|
||||||
Clocked In
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted mb-1">Since</p>
|
|
||||||
<p class="fw-semibold mb-3">@openEntry!.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
|
|
||||||
<p class="text-muted small mb-4">Entry #@openEntry.Id</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<span class="badge bg-secondary fs-6 px-3 py-2">
|
<span class="badge bg-secondary fs-6 px-3 py-2">
|
||||||
@@ -67,19 +61,72 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-muted mb-4">You are not currently clocked in.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (isClockedIn)
|
@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"
|
<button id="btn-clock-out" class="btn btn-danger btn-lg w-100"
|
||||||
data-entry-id="@openEntry!.Id">
|
data-entry-id="@openEntry!.Id">
|
||||||
<i class="bi bi-box-arrow-right me-2"></i>Clock Out
|
<i class="bi bi-box-arrow-right me-2"></i>Clock Out
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button id="btn-clock-in" class="btn btn-success btn-lg w-100">
|
<button id="btn-return-from-break" class="btn btn-success btn-lg w-100">
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>Clock In
|
<i class="bi bi-arrow-return-left me-2"></i>Return to Work
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +171,7 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Clock In</th>
|
<th>Clock In</th>
|
||||||
<th>Clock Out</th>
|
<th>Clock Out</th>
|
||||||
<th>Hours</th>
|
<th>Hours</th>
|
||||||
@@ -134,7 +182,9 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var day in grouped)
|
@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();
|
var segments = day.OrderBy(e => e.ClockInTime).ToList();
|
||||||
|
|
||||||
@foreach (var entry in segments)
|
@foreach (var entry in segments)
|
||||||
@@ -147,6 +197,14 @@
|
|||||||
<br /><small class="text-muted">@Math.Round(dayTotal, 2)h total</small>
|
<br /><small class="text-muted">@Math.Round(dayTotal, 2)h total</small>
|
||||||
</td>
|
</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>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (entry.ClockOutTime.HasValue)
|
@if (entry.ClockOutTime.HasValue)
|
||||||
@@ -205,6 +263,11 @@
|
|||||||
<button id="btn-manager-load" class="btn btn-sm btn-outline-primary">
|
<button id="btn-manager-load" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-search me-1"></i>Load
|
<i class="bi bi-search me-1"></i>Load
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div class="card-body p-0" id="manager-history-container">
|
<div class="card-body p-0" id="manager-history-container">
|
||||||
@@ -216,6 +279,56 @@
|
|||||||
|
|
||||||
</div>
|
</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 ────────────────────────────────────────────────────────── *@
|
@* ── Edit Entry Modal ────────────────────────────────────────────────────────── *@
|
||||||
@if (isManager)
|
@if (isManager)
|
||||||
{
|
{
|
||||||
@@ -257,7 +370,9 @@
|
|||||||
Timeclock.init({
|
Timeclock.init({
|
||||||
isClockedIn: @Json.Serialize(isClockedIn),
|
isClockedIn: @Json.Serialize(isClockedIn),
|
||||||
openEntryId: @Json.Serialize(openEntry?.Id),
|
openEntryId: @Json.Serialize(openEntry?.Id),
|
||||||
isManager: @Json.Serialize(isManager)
|
isManager: @Json.Serialize(isManager),
|
||||||
|
isOnBreak: @Json.Serialize(isOnBreak),
|
||||||
|
allowMultiplePunches: @Json.Serialize(allowMultiplePunches)
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@using PowderCoating.Core.Enums
|
||||||
@model List<PowderCoating.Application.DTOs.Timeclock.EmployeeClockEntryDto>
|
@model List<PowderCoating.Application.DTOs.Timeclock.EmployeeClockEntryDto>
|
||||||
@{
|
@{
|
||||||
var nowUtc = DateTime.UtcNow;
|
var nowUtc = DateTime.UtcNow;
|
||||||
@@ -13,7 +14,8 @@ else
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Employee</th>
|
<th>Employee</th>
|
||||||
<th>Clocked In Since</th>
|
<th>Status</th>
|
||||||
|
<th>Since</th>
|
||||||
<th>Elapsed</th>
|
<th>Elapsed</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -23,8 +25,28 @@ else
|
|||||||
var elapsed = (nowUtc - entry.ClockInTime).TotalHours;
|
var elapsed = (nowUtc - entry.ClockInTime).TotalHours;
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-semibold">@entry.UserDisplayName</td>
|
<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>@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>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ var Timeclock = (function () {
|
|||||||
scheduleWhosInRefresh();
|
scheduleWhosInRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Clock In / Out ────────────────────────────────────────────────────── */
|
/* ── Clock In / Out / Break / Lunch / Return ───────────────────────────── */
|
||||||
function bindClockButtons() {
|
function bindClockButtons() {
|
||||||
var btnIn = document.getElementById('btn-clock-in');
|
var btnIn = document.getElementById('btn-clock-in');
|
||||||
var btnOut = document.getElementById('btn-clock-out');
|
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) {
|
if (btnIn) {
|
||||||
btnIn.addEventListener('click', function () {
|
btnIn.addEventListener('click', function () {
|
||||||
btnIn.disabled = true;
|
btnIn.disabled = true;
|
||||||
postJson('/Timeclock/ClockIn', {})
|
postJson('/Timeclock/ClockIn', {})
|
||||||
.then(function (r) { location.reload(); })
|
.then(function () { location.reload(); })
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
showFeedback(err, 'danger');
|
showFeedback(err, 'danger');
|
||||||
btnIn.disabled = false;
|
btnIn.disabled = false;
|
||||||
@@ -31,13 +34,40 @@ var Timeclock = (function () {
|
|||||||
btnOut.disabled = true;
|
btnOut.disabled = true;
|
||||||
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
|
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
|
||||||
postJson('/Timeclock/ClockOut', { entryId: entryId })
|
postJson('/Timeclock/ClockOut', { entryId: entryId })
|
||||||
.then(function (r) { location.reload(); })
|
.then(function () { location.reload(); })
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
showFeedback(err, 'danger');
|
showFeedback(err, 'danger');
|
||||||
btnOut.disabled = false;
|
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 ──────────────────────────────────────── */
|
/* ── Who's In refresh every 60 s ──────────────────────────────────────── */
|
||||||
@@ -58,7 +88,7 @@ var Timeclock = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Manager: edit / delete ────────────────────────────────────────────── */
|
/* ── Manager: edit / delete / manual entry ────────────────────────────── */
|
||||||
function bindManagerEvents() {
|
function bindManagerEvents() {
|
||||||
if (!_cfg.isManager) return;
|
if (!_cfg.isManager) return;
|
||||||
|
|
||||||
@@ -72,6 +102,21 @@ var Timeclock = (function () {
|
|||||||
|
|
||||||
var btnLoad = document.getElementById('btn-manager-load');
|
var btnLoad = document.getElementById('btn-manager-load');
|
||||||
if (btnLoad) btnLoad.addEventListener('click', loadManagerHistory);
|
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) {
|
function openEditModal(btn) {
|
||||||
@@ -109,6 +154,44 @@ var Timeclock = (function () {
|
|||||||
.catch(function (err) { alert('Error: ' + err); });
|
.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() {
|
function loadManagerHistory() {
|
||||||
var from = document.getElementById('manager-from').value;
|
var from = document.getElementById('manager-from').value;
|
||||||
var to = document.getElementById('manager-to').value;
|
var to = document.getElementById('manager-to').value;
|
||||||
@@ -128,12 +211,18 @@ var Timeclock = (function () {
|
|||||||
return;
|
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 rows = entries.map(function (e) {
|
||||||
var inTime = new Date(e.clockInTime).toLocaleString();
|
var inTime = new Date(e.clockInTime).toLocaleString();
|
||||||
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '—';
|
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '—';
|
||||||
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
||||||
|
var typeBadge = TYPE_LABELS[e.entryType] || TYPE_LABELS[0];
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td>' + escHtml(e.userDisplayName) + '</td>' +
|
'<td>' + escHtml(e.userDisplayName) + '</td>' +
|
||||||
|
'<td>' + typeBadge + '</td>' +
|
||||||
'<td>' + inTime + '</td>' +
|
'<td>' + inTime + '</td>' +
|
||||||
'<td>' + outTime + '</td>' +
|
'<td>' + outTime + '</td>' +
|
||||||
'<td>' + hours + '</td>' +
|
'<td>' + hours + '</td>' +
|
||||||
@@ -143,7 +232,7 @@ var Timeclock = (function () {
|
|||||||
|
|
||||||
container.innerHTML = '<table class="table table-hover table-sm mb-0">' +
|
container.innerHTML = '<table class="table table-hover table-sm mb-0">' +
|
||||||
'<thead class="table-light"><tr>' +
|
'<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>';
|
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user