Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries) - KioskPin added to ApplicationUser; TimeclockKioskToken added to Company - TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete, tablet kiosk with device-cookie auth, PIN management via Users edit page - Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out - Attendance report at /Reports/Attendance with weekly subtotal rows - Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment) - AllowCustomFormulas wired through PlatformSubscriptionController + subscription views - Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating) - Help article (Help/Timeclock.cshtml) and AI knowledge base updated - Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,7 @@ public class CompanySettingsController : Controller
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||
dto.AllowSms = planConfig?.AllowSms ?? false;
|
||||
ViewBag.AllowCustomFormulas = AllowCustomFormulas();
|
||||
dto.SmsEnabled = company.SmsEnabled;
|
||||
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
||||
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
||||
@@ -2971,10 +2972,13 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ─── Custom Formula Item Templates ──────────────────────────────────────────
|
||||
|
||||
private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||
|
||||
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomItemTemplate(int id)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -2987,6 +2991,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomItemTemplates()
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||
t => t.CompanyId == companyId);
|
||||
@@ -2998,6 +3003,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
@@ -3019,6 +3025,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
@@ -3041,6 +3048,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -3059,6 +3067,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -3104,17 +3113,57 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a NCalc formula with the supplied variable values.
|
||||
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
|
||||
/// in the Application/Infrastructure layer.
|
||||
/// Evaluates a NCalc formula with the supplied variable values, automatically injecting
|
||||
/// three read-only shop-rate variables sourced from the company's operating costs:
|
||||
/// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
|
||||
/// User-supplied variables take precedence so the test panel can override them.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||
public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
|
||||
// Inject shop-rate system variables; user-supplied values win if the same key appears in both.
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId != null)
|
||||
{
|
||||
var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
|
||||
if (costs != null)
|
||||
req = InjectShopRateVariables(req, costs);
|
||||
}
|
||||
|
||||
var result = _formulaAiService.EvaluateFormula(req);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
|
||||
/// from <paramref name="costs"/> into the request's variable map without overwriting any key
|
||||
/// the caller already set (so the test panel can still override these values explicitly).
|
||||
/// </summary>
|
||||
private static EvaluateFormulaRequest InjectShopRateVariables(
|
||||
EvaluateFormulaRequest req, CompanyOperatingCosts costs)
|
||||
{
|
||||
var vars = System.Text.Json.JsonSerializer
|
||||
.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();
|
||||
|
||||
void Inject(string key, decimal value)
|
||||
{
|
||||
if (!vars.ContainsKey(key))
|
||||
vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
|
||||
}
|
||||
|
||||
Inject("standard_labor_rate", costs.StandardLaborRate);
|
||||
Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
|
||||
Inject("markup_pct", costs.GeneralMarkupPercentage);
|
||||
|
||||
return new EvaluateFormulaRequest
|
||||
{
|
||||
Formula = req.Formula,
|
||||
VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls Claude to generate a formula template from a natural-language description
|
||||
/// and an optional diagram image uploaded in the same multipart form.
|
||||
@@ -3122,6 +3171,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
return Json(new { success = false, error = "Description is required." });
|
||||
|
||||
|
||||
@@ -134,5 +134,14 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Timeclock help article explaining clock in/out, multi-segment days, the kiosk tablet
|
||||
/// mode with PIN authentication, manager edit/delete tools, and the Attendance report.
|
||||
/// </summary>
|
||||
public IActionResult Timeclock()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
||||
AllowSms = c.AllowSms,
|
||||
AllowCustomFormulas = c.AllowCustomFormulas,
|
||||
IsActive = c.IsActive,
|
||||
SortOrder = c.SortOrder
|
||||
}).ToList();
|
||||
@@ -106,6 +107,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
||||
AllowSms = config.AllowSms,
|
||||
AllowCustomFormulas = config.AllowCustomFormulas,
|
||||
IsActive = config.IsActive
|
||||
};
|
||||
|
||||
@@ -152,6 +154,7 @@ public class PlatformSubscriptionController : Controller
|
||||
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
||||
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
||||
config.AllowSms = dto.AllowSms;
|
||||
config.AllowCustomFormulas = dto.AllowCustomFormulas;
|
||||
config.IsActive = dto.IsActive;
|
||||
|
||||
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
||||
|
||||
@@ -2475,6 +2475,142 @@ public class ReportsController : Controller
|
||||
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Timeclock / Attendance
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Attendance report: daily punch detail and weekly summaries per employee.
|
||||
/// Managers can see all employees; Workers see their own history only.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Attendance(DateTime? from, DateTime? to)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Forbid();
|
||||
|
||||
var companyId = currentUser.CompanyId;
|
||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||
|
||||
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
|
||||
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
|
||||
|
||||
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.CompanyId == companyId
|
||||
&& e.ClockInTime >= start
|
||||
&& e.ClockInTime < end
|
||||
&& (isManager || e.UserId == currentUser.Id),
|
||||
false,
|
||||
e => e.User);
|
||||
|
||||
var grouped = entries
|
||||
.GroupBy(e => e.UserId)
|
||||
.Select(g => new AttendanceEmployeeRow
|
||||
{
|
||||
UserId = g.Key,
|
||||
DisplayName = g.First().User?.FullName ?? "Unknown",
|
||||
TotalHours = Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2),
|
||||
Days = g.GroupBy(e => e.ClockInTime.Date)
|
||||
.OrderByDescending(d => d.Key)
|
||||
.Select(d => new AttendanceDayRow
|
||||
{
|
||||
Date = d.Key,
|
||||
DayTotal = Math.Round(d.Sum(e => e.HoursWorked ?? 0), 2),
|
||||
Segments = d.OrderBy(e => e.ClockInTime).ToList()
|
||||
}).ToList()
|
||||
})
|
||||
.OrderBy(r => r.DisplayName)
|
||||
.ToList();
|
||||
|
||||
ViewBag.From = start;
|
||||
ViewBag.To = end.AddDays(-1);
|
||||
ViewBag.IsManager = isManager;
|
||||
return View(grouped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the attendance data for the selected date range as a CSV file suitable for
|
||||
/// import into payroll software. Each row is one clock segment (punch pair); employee name,
|
||||
/// date, clock in/out times, segment hours, day total, and week total are all repeated on
|
||||
/// every row so the file is fully self-contained in flat-file format.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> AttendanceCsv(DateTime? from, DateTime? to)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Forbid();
|
||||
|
||||
var companyId = currentUser.CompanyId;
|
||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||
|
||||
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
|
||||
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
|
||||
|
||||
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.CompanyId == companyId
|
||||
&& e.ClockInTime >= start
|
||||
&& e.ClockInTime < end
|
||||
&& (isManager || e.UserId == currentUser.Id),
|
||||
false,
|
||||
e => e.User);
|
||||
|
||||
// Build flat CSV rows sorted by employee name → date → clock-in time
|
||||
var rows = entries
|
||||
.OrderBy(e => e.User?.FullName ?? "")
|
||||
.ThenBy(e => e.ClockInTime)
|
||||
.ToList();
|
||||
|
||||
// Pre-compute day totals and week totals per user
|
||||
var dayTotals = entries
|
||||
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
|
||||
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||
|
||||
// ISO week: group by (UserId, year, week number) where week starts Monday
|
||||
static int IsoWeek(DateTime d) =>
|
||||
System.Globalization.ISOWeek.GetWeekOfYear(d);
|
||||
|
||||
var weekTotals = entries
|
||||
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
|
||||
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Employee Name,Date,Day of Week,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
|
||||
|
||||
foreach (var entry in rows)
|
||||
{
|
||||
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
|
||||
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
|
||||
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
|
||||
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
|
||||
var clockOut = entry.ClockOutTime.HasValue
|
||||
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
|
||||
: "In Progress";
|
||||
var segHrs = entry.HoursWorked.HasValue
|
||||
? entry.HoursWorked.Value.ToString("F2")
|
||||
: "";
|
||||
var dayKey = (entry.UserId, entry.ClockInTime.Date);
|
||||
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
|
||||
var dayTotal = dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
|
||||
var wkTotal = weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
|
||||
var notes = CsvEscape(entry.Notes ?? "");
|
||||
|
||||
sb.AppendLine($"{name},{date},{dow},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
|
||||
}
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
|
||||
var filename = $"{safeName}_Attendance_{start:yyyyMMdd}_to_{end.AddDays(-1):yyyyMMdd}.csv";
|
||||
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
|
||||
}
|
||||
|
||||
private static string CsvEscape(string value)
|
||||
{
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
|
||||
/// company name into AI prompts so the generated text refers to the actual business, not a
|
||||
@@ -2628,3 +2764,18 @@ public class Vendor1099Row
|
||||
public bool NeedsForm { get; set; }
|
||||
}
|
||||
|
||||
public class AttendanceEmployeeRow
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public decimal TotalHours { get; set; }
|
||||
public List<AttendanceDayRow> Days { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AttendanceDayRow
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public decimal DayTotal { get; set; }
|
||||
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -301,6 +301,9 @@ public class SubscriptionManagementController : Controller
|
||||
case "AllowOnlinePayments":
|
||||
config.AllowOnlinePayments = enabled;
|
||||
break;
|
||||
case "AllowCustomFormulas":
|
||||
config.AllowCustomFormulas = enabled;
|
||||
break;
|
||||
default:
|
||||
return BadRequest("Unknown feature.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Timeclock;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles employee facility-level timeclock: clock in/out, manager edits, and the
|
||||
/// tablet kiosk page with PIN-based authentication.
|
||||
/// Two authentication modes:
|
||||
/// - Main app actions: standard [Authorize] cookie auth (normal nav users)
|
||||
/// - Kiosk actions: device-cookie auth (shared tablet, no user login required)
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class TimeclockController : Controller
|
||||
{
|
||||
private const string KioskCookieName = "TimeclockKioskDevice";
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
|
||||
private readonly ILogger<TimeclockController> _logger;
|
||||
|
||||
/// <summary>Initialises dependencies for the timeclock controller.</summary>
|
||||
public TimeclockController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IPasswordHasher<ApplicationUser> passwordHasher,
|
||||
ILogger<TimeclockController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_passwordHasher = passwordHasher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN APP — dashboard
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Timeclock dashboard: current user's punch button, "who's in" grid, and personal history.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Forbid();
|
||||
|
||||
var companyId = currentUser.CompanyId;
|
||||
|
||||
// Current user's open entry (null = clocked out)
|
||||
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||
e => e.UserId == currentUser.Id && e.ClockOutTime == null && e.CompanyId == companyId);
|
||||
|
||||
// All open entries for the "Who's In" table
|
||||
var activeEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.ClockOutTime == null && e.CompanyId == companyId,
|
||||
false,
|
||||
e => e.User);
|
||||
|
||||
// Current user's last 14 days of entries
|
||||
var since = DateTime.UtcNow.Date.AddDays(-14);
|
||||
var myHistory = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.UserId == currentUser.Id && e.ClockInTime >= since && e.CompanyId == companyId);
|
||||
|
||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||
|
||||
ViewBag.CurrentUser = currentUser;
|
||||
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
|
||||
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
|
||||
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
|
||||
ViewBag.IsManager = isManager;
|
||||
ViewBag.NowUtc = DateTime.UtcNow;
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN APP — clock in / out / status
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Returns the current user's open clock entry, or null if clocked out.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> MyStatus()
|
||||
{
|
||||
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 Json(new { isClockedIn = false });
|
||||
|
||||
var elapsed = (decimal)(DateTime.UtcNow - openEntry.ClockInTime).TotalHours;
|
||||
return Json(new
|
||||
{
|
||||
isClockedIn = true,
|
||||
entryId = openEntry.Id,
|
||||
clockInTime = openEntry.ClockInTime,
|
||||
elapsedHours = Math.Round(elapsed, 2)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Returns all employees with an open clock entry (for the "Who's In" widget).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ActiveNow()
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
|
||||
var active = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.ClockOutTime == null && e.CompanyId == companyId,
|
||||
false,
|
||||
e => e.User);
|
||||
|
||||
var result = active.Select(e => new
|
||||
{
|
||||
userId = e.UserId,
|
||||
displayName = e.User?.FullName ?? "Unknown",
|
||||
clockInTime = e.ClockInTime,
|
||||
entryId = e.Id
|
||||
});
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>Clocks the current user in. Returns an error if already clocked in.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> ClockIn([FromBody] ClockInRequest request)
|
||||
{
|
||||
var userId = _userManager.GetUserId(User)!;
|
||||
var companyId = GetCurrentCompanyId();
|
||||
|
||||
var alreadyOpen = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
|
||||
|
||||
if (alreadyOpen != null)
|
||||
return BadRequest(new { message = "You are already clocked in. Please clock out first." });
|
||||
|
||||
var entry = new EmployeeClockEntry
|
||||
{
|
||||
UserId = userId,
|
||||
CompanyId = companyId,
|
||||
ClockInTime = DateTime.UtcNow,
|
||||
Notes = request.Notes?.Trim()
|
||||
};
|
||||
|
||||
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("User {UserId} clocked in (entry {EntryId})", userId, entry.Id);
|
||||
return Json(new { success = true, entryId = entry.Id, clockInTime = entry.ClockInTime });
|
||||
}
|
||||
|
||||
/// <summary>Clocks the current user out, storing the elapsed hours on the entry.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> ClockOut([FromBody] ClockOutRequest request)
|
||||
{
|
||||
var userId = _userManager.GetUserId(User)!;
|
||||
var companyId = GetCurrentCompanyId();
|
||||
|
||||
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.EntryId);
|
||||
if (entry == null || entry.UserId != userId || entry.CompanyId != companyId)
|
||||
return NotFound(new { message = "Clock entry not found." });
|
||||
|
||||
if (entry.ClockOutTime != null)
|
||||
return BadRequest(new { message = "This entry is already clocked out." });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
entry.ClockOutTime = now;
|
||||
entry.HoursWorked = Math.Round((decimal)(now - entry.ClockInTime).TotalHours, 2);
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
entry.Notes = request.Notes.Trim();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("User {UserId} clocked out (entry {EntryId}, {Hours}h)", userId, entry.Id, entry.HoursWorked);
|
||||
return Json(new { success = true, hoursWorked = entry.HoursWorked, clockOutTime = entry.ClockOutTime });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN APP — manager: history, edit, delete
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Returns paginated clock entries. Managers can pass any userId; others only see their own.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> History(DateTime? from, DateTime? to, string? userId)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
if (currentUser == null) return Forbid();
|
||||
|
||||
var companyId = currentUser.CompanyId;
|
||||
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||
var filterUser = isManager ? userId : currentUser.Id;
|
||||
|
||||
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
|
||||
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
|
||||
|
||||
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.CompanyId == companyId
|
||||
&& e.ClockInTime >= start
|
||||
&& e.ClockInTime < end
|
||||
&& (filterUser == null || e.UserId == filterUser),
|
||||
false,
|
||||
e => e.User);
|
||||
|
||||
var dtos = entries.OrderByDescending(e => e.ClockInTime)
|
||||
.Select(e => MapEntry(e, e.User))
|
||||
.ToList();
|
||||
|
||||
return Json(dtos);
|
||||
}
|
||||
|
||||
/// <summary>Manager-only: edit clock-in/out times or notes on any entry in the company.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Edit([FromBody] EditClockEntryRequest request)
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.Id);
|
||||
if (entry == null || entry.CompanyId != companyId)
|
||||
return NotFound(new { message = "Entry not found." });
|
||||
|
||||
entry.ClockInTime = request.ClockInTime;
|
||||
entry.ClockOutTime = request.ClockOutTime;
|
||||
entry.Notes = request.Notes?.Trim();
|
||||
|
||||
if (request.ClockOutTime.HasValue)
|
||||
entry.HoursWorked = Math.Round(
|
||||
(decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2);
|
||||
else
|
||||
entry.HoursWorked = null;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>Manager-only: soft-delete a clock entry.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Delete([FromBody] int id)
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(id);
|
||||
if (entry == null || entry.CompanyId != companyId)
|
||||
return NotFound(new { message = "Entry not found." });
|
||||
|
||||
await _unitOfWork.EmployeeClockEntries.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// KIOSK — device activation
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Manager activates a tablet device by navigating to this URL.
|
||||
/// Generates (or regenerates) the company's TimeclockKioskToken and writes the device cookie.
|
||||
/// </summary>
|
||||
[HttpGet("Timeclock/Kiosk/Activate")]
|
||||
public async Task<IActionResult> KioskActivate()
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
company.TimeclockKioskToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
WriteTimeclockKioskCookie(companyId, company.TimeclockKioskToken);
|
||||
|
||||
TempData["Success"] = "Timeclock kiosk activated on this device.";
|
||||
return RedirectToAction(nameof(Kiosk));
|
||||
}
|
||||
|
||||
/// <summary>Deactivates the timeclock kiosk — clears the token and removes the cookie.</summary>
|
||||
[HttpPost("Timeclock/Kiosk/Deactivate")]
|
||||
public async Task<IActionResult> KioskDeactivate()
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company != null)
|
||||
{
|
||||
company.TimeclockKioskToken = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
DeleteTimeclockKioskCookie();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// KIOSK — tablet UI
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tablet timeclock kiosk page. Requires device-cookie auth; no user login needed.
|
||||
/// Shows employee tiles for all active employees who have a KioskPin set.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("Timeclock/Kiosk")]
|
||||
public async Task<IActionResult> Kiosk()
|
||||
{
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null)
|
||||
return View("KioskError", "This device is not activated as a timeclock kiosk. Ask a manager to activate it at Timeclock › Activate Kiosk.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.TimeclockKioskToken != cookie.Value.token)
|
||||
return View("KioskError", "Kiosk activation is invalid or has been revoked. Ask a manager to re-activate this device.");
|
||||
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.CompanyId = company.Id;
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of active employees who have a kiosk PIN set, along with their current clock-in status.
|
||||
/// Called by the kiosk page on load and after each punch.
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpGet]
|
||||
public async Task<IActionResult> KioskEmployees()
|
||||
{
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
var companyId = cookie.Value.companyId;
|
||||
|
||||
var users = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.KioskPin != null)
|
||||
.ToListAsync();
|
||||
|
||||
// Get current open entries for this company
|
||||
var openUserIds = (await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.ClockOutTime == null && e.CompanyId == companyId))
|
||||
.Select(e => e.UserId)
|
||||
.ToHashSet();
|
||||
|
||||
var employees = users.Select(u => new KioskEmployeeDto
|
||||
{
|
||||
UserId = u.Id,
|
||||
DisplayName = u.FullName,
|
||||
Initials = BuildInitials(u),
|
||||
IsClockedIn = openUserIds.Contains(u.Id)
|
||||
}).OrderBy(e => e.DisplayName).ToList();
|
||||
|
||||
return Json(employees);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kiosk punch endpoint: validates PIN and clocks the employee in or out automatically.
|
||||
/// Returns the action taken ("clockIn" or "clockOut") and the segment/daily totals.
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpPost]
|
||||
public async Task<IActionResult> KioskPunch([FromBody] KioskPunchRequest request)
|
||||
{
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
var companyId = cookie.Value.companyId;
|
||||
|
||||
var user = await _userManager.FindByIdAsync(request.UserId);
|
||||
if (user == null || user.CompanyId != companyId || !user.IsActive || user.KioskPin == null)
|
||||
return BadRequest(new { message = "Employee not found or kiosk disabled." });
|
||||
|
||||
// Verify PIN
|
||||
var hashResult = _passwordHasher.VerifyHashedPassword(user, user.KioskPin, request.Pin);
|
||||
if (hashResult == PasswordVerificationResult.Failed)
|
||||
return BadRequest(new { message = "Incorrect PIN. Please try again." });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Check for open entry
|
||||
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||
e => e.UserId == user.Id && e.ClockOutTime == null && e.CompanyId == companyId);
|
||||
|
||||
string action;
|
||||
decimal segmentHours = 0;
|
||||
|
||||
if (openEntry != null)
|
||||
{
|
||||
// Clock out
|
||||
openEntry.ClockOutTime = now;
|
||||
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
action = "clockOut";
|
||||
segmentHours = openEntry.HoursWorked.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clock in
|
||||
var entry = new EmployeeClockEntry
|
||||
{
|
||||
UserId = user.Id,
|
||||
CompanyId = companyId,
|
||||
ClockInTime = now
|
||||
};
|
||||
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
action = "clockIn";
|
||||
}
|
||||
|
||||
// Compute today's running total (all completed segments + any still-open segment)
|
||||
var todayStart = now.Date;
|
||||
var todayEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= todayStart);
|
||||
|
||||
decimal dailyTotal = todayEntries.Sum(e =>
|
||||
e.HoursWorked ?? (decimal)(now - e.ClockInTime).TotalHours);
|
||||
dailyTotal = Math.Round(dailyTotal, 2);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
action,
|
||||
displayName = user.FullName,
|
||||
timestamp = now,
|
||||
segmentHours,
|
||||
dailyTotal,
|
||||
segmentCount = todayEntries.Count()
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PIN management (called from Users UI via AJAX)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Sets or clears a 4-digit kiosk PIN for the specified employee. Manager-only.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetKioskPin(string userId, string? pin)
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user == null || user.CompanyId != companyId)
|
||||
return NotFound(new { message = "User not found." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pin))
|
||||
{
|
||||
user.KioskPin = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (pin.Length != 4 || !pin.All(char.IsDigit))
|
||||
return BadRequest(new { message = "PIN must be exactly 4 digits." });
|
||||
user.KioskPin = _passwordHasher.HashPassword(user, pin);
|
||||
}
|
||||
|
||||
await _userManager.UpdateAsync(user);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private int GetCurrentCompanyId()
|
||||
{
|
||||
var claim = User.FindFirst("CompanyId")?.Value;
|
||||
return int.TryParse(claim, out int id) ? id : 0;
|
||||
}
|
||||
|
||||
private async Task<bool> IsManagerAsync()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
return user?.CompanyRole is "CompanyAdmin" or "Manager";
|
||||
}
|
||||
|
||||
private (int companyId, string token)? ReadTimeclockKioskCookie()
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(KioskCookieName, out var raw) || string.IsNullOrEmpty(raw))
|
||||
return null;
|
||||
var parts = raw.Split(':', 2);
|
||||
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
|
||||
return null;
|
||||
return (id, parts[1]);
|
||||
}
|
||||
|
||||
private void WriteTimeclockKioskCookie(int companyId, string token)
|
||||
{
|
||||
Response.Cookies.Append(KioskCookieName, $"{companyId}:{token}", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromDays(365)
|
||||
});
|
||||
}
|
||||
|
||||
private void DeleteTimeclockKioskCookie()
|
||||
{
|
||||
Response.Cookies.Delete(KioskCookieName);
|
||||
}
|
||||
|
||||
private static EmployeeClockEntryDto MapEntry(EmployeeClockEntry e, ApplicationUser? user) =>
|
||||
new()
|
||||
{
|
||||
Id = e.Id,
|
||||
UserId = e.UserId,
|
||||
UserDisplayName = user?.FullName ?? "Unknown",
|
||||
ClockInTime = e.ClockInTime,
|
||||
ClockOutTime = e.ClockOutTime,
|
||||
HoursWorked = e.HoursWorked,
|
||||
Notes = e.Notes
|
||||
};
|
||||
|
||||
private static string BuildInitials(ApplicationUser u)
|
||||
{
|
||||
var first = string.IsNullOrEmpty(u.FirstName) ? "" : u.FirstName[0].ToString();
|
||||
var last = string.IsNullOrEmpty(u.LastName) ? "" : u.LastName[0].ToString();
|
||||
return (first + last).ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user