Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export

- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries)
- KioskPin added to ApplicationUser; TimeclockKioskToken added to Company
- TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete,
  tablet kiosk with device-cookie auth, PIN management via Users edit page
- Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out
- Attendance report at /Reports/Attendance with weekly subtotal rows
- Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment)
- AllowCustomFormulas wired through PlatformSubscriptionController + subscription views
- Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating)
- Help article (Help/Timeclock.cshtml) and AI knowledge base updated
- Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:53:13 -04:00
parent f625be01a3
commit 6c2fe6e1c4
40 changed files with 24125 additions and 16 deletions
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,52 @@
namespace PowderCoating.Application.DTOs.Timeclock;
public class EmployeeClockEntryDto
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public string UserDisplayName { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public decimal? HoursWorked { get; set; }
public string? Notes { get; set; }
public bool IsOpen => ClockOutTime == null;
}
public class ClockInRequest
{
public string? Notes { get; set; }
}
public class ClockOutRequest
{
public int EntryId { get; set; }
public string? Notes { get; set; }
}
/// <summary>
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
/// The server determines whether to clock in or clock out based on the employee's open entry.
/// </summary>
public class KioskPunchRequest
{
public string UserId { get; set; } = string.Empty;
public string Pin { get; set; } = string.Empty;
}
public class EditClockEntryRequest
{
public int Id { get; set; }
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
public class KioskEmployeeDto
{
public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Initials { get; set; } = string.Empty;
/// <summary>True when the employee has an open clock entry right now.</summary>
public bool IsClockedIn { get; set; }
}
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
// Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false;
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
public string? KioskPin { get; set; }
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
@@ -133,6 +133,12 @@ public class Company : BaseEntity
/// </summary>
public string? KioskActivationToken { get; set; }
/// <summary>
/// Device activation token for the Timeclock kiosk tablet.
/// Null = timeclock kiosk not activated on any device.
/// </summary>
public string? TimeclockKioskToken { get; set; }
// Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -0,0 +1,24 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Facility-level clock-in/clock-out record for an employee.
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
/// </summary>
public class EmployeeClockEntry : BaseEntity
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
/// <summary>Null means the employee is currently clocked in.</summary>
public DateTime? ClockOutTime { get; set; }
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
public decimal? HoursWorked { get; set; }
public string? Notes { get; set; }
public virtual ApplicationUser User { get; set; } = null!;
}
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
public bool AllowSms { get; set; } = false;
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
public bool AllowCustomFormulas { get; set; } = false;
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -158,6 +158,9 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
// Employee Timeclock
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -378,6 +378,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
// Employee Timeclock
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
/// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -771,6 +775,24 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
// Custom Formula Templates — tenant-filtered + soft delete
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Employee Timeclock — tenant-filtered + soft delete
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
modelBuilder.Entity<EmployeeClockEntry>()
.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Restrict);
// Composite index for "who's clocked in today" and date-range attendance reports
modelBuilder.Entity<EmployeeClockEntry>()
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
// Account self-referencing hierarchy
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddEmployeeTimeclock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskPin",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "EmployeeClockEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
table.ForeignKey(
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
table: "EmployeeClockEntries",
columns: new[] { "CompanyId", "ClockInTime" });
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_UserId",
table: "EmployeeClockEntries",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployeeClockEntries");
migrationBuilder.DropColumn(
name: "KioskPin",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTimeclockKioskToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TimeclockKioskToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TimeclockKioskToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<string>("KioskPin")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
@@ -1923,6 +1926,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)");
b.Property<string>("TimeclockKioskToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@@ -3034,6 +3040,63 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Deposits");
});
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ClockInTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ClockOutTime")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("HoursWorked")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("CompanyId", "ClockInTime");
b.ToTable("EmployeeClockEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{
b.Property<int>("Id")
@@ -6796,7 +6859,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290),
CreatedAt = new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6807,7 +6870,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297),
CreatedAt = new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6818,7 +6881,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298),
CreatedAt = new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -9196,6 +9259,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("RecordedBy");
});
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -123,6 +123,9 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions;
// Employee Timeclock
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
@@ -460,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context);
/// <summary>Repository for <see cref="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
@@ -51,11 +51,17 @@ Respond ONLY with a valid JSON object matching this exact schema — no markdown
""verificationResult"": number
}
Built-in shop-rate variables (injected automatically at eval time — do NOT redeclare them as fields):
standard_labor_rate — shop billing rate in $/hr (e.g. hours * standard_labor_rate)
additional_coat_labor_pct — extra-coat labor surcharge 0100 (e.g. cost * (1 + additional_coat_labor_pct/100))
markup_pct — general markup percentage 0100 (e.g. cost * (1 + markup_pct/100))
Rules:
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
- Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array — they are injected automatically
- verificationInputs and verificationResult must use the exact field names and formula you generated
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
- For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying
@@ -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 &rsaquo; 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();
}
}
@@ -1435,6 +1435,31 @@ public static class HelpKnowledgeBase
- Flat panel: fields l_in/w_in l_in * w_in / 144 * rate
Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button
Help article: Help Custom Formula Item Templates
---
**Employee Timeclock (/Timeclock):**
Facility-level clock in/out for employees tracks when workers arrive and leave, separate from Job Time Entries which log hours against a specific job.
Multiple punches per day fully supported (clock out for lunch, clock in again, clock out, etc.); each segment tracked separately with its own in/out and hours.
Only constraint: you cannot clock in while already clocked in clock out first.
Dashboard (/Timeclock):
- Your clock status card: shows "Clocked In" (with time) or "Clocked Out"; one-click Clock In / Clock Out buttons
- Who's In table: all employees currently clocked in, with elapsed time; refreshes every 60 seconds
- My Recent History: last 14 days of your own punch segments grouped by day with daily totals
- Manager section (CompanyAdmin and Manager roles only): Team History with date-range filter; edit/delete any entry for any employee in the company
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.
Kiosk mode (/Timeclock/Kiosk):
A shared shop tablet page that does not require login. Employees tap their name tile enter 4-digit PIN system automatically clocks in (if out) or clocks out (if in). Confirmation screen shows time, segment hours, and today's running total. Returns to employee grid after 4 seconds.
Activation: Manager navigates to /Timeclock/Kiosk/Activate generates device cookie; kiosk then runs without login. Re-visiting /Kiosk/Activate revokes the old device and re-activates the current one.
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.
Attendance Report (/Reports/Attendance): shows daily punch detail per employee with date-range filter. Each employee card groups segments by day (with day totals) and by ISO week (with weekly subtotal rows). Managers see all employees; workers see their own history only.
CSV Export: "Export CSV (Payroll)" button downloads a flat CSV with one row per punch segment columns: Employee Name, Date, Day of Week, Clock In, Clock Out, Segment Hours, Day Total Hours, Week Total Hours, Notes. Suitable for import into payroll software or handoff to a payroll provider.
Help article: Help Timeclock
""";
}
@@ -96,6 +96,7 @@ public class SubscriptionMiddleware
context.Items["AllowOnlinePayments"] = true;
context.Items["AllowAccounting"] = true;
context.Items["AllowSms"] = true;
context.Items["AllowCustomFormulas"] = true;
await _next(context);
return;
}
@@ -145,6 +146,8 @@ public class SubscriptionMiddleware
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
context.Items["AllowSms"] = company.IsComped
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
context.Items["AllowCustomFormulas"] = company.IsComped
|| (planConfig?.AllowCustomFormulas ?? false);
if (company.IsComped)
{
@@ -106,11 +106,14 @@
<i class="bi bi-tablet"></i> Kiosk
</button>
</li>
@if (ViewBag.AllowCustomFormulas == true)
{
<li class="nav-item" role="presentation">
<button class="nav-link" id="custom-formulas-tab" data-bs-toggle="tab" data-bs-target="#custom-formulas" type="button" role="tab">
<i class="bi bi-calculator"></i> Custom Formulas
</button>
</li>
}
</ul>
<!-- Tabs Content -->
@@ -2060,6 +2063,8 @@
</div>
<!-- ── Custom Formula Item Templates ──────────────────────────────── -->
@if (ViewBag.AllowCustomFormulas == true)
{
<div class="tab-pane fade" id="custom-formulas" role="tabpanel">
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -2098,6 +2103,7 @@
</div>
</div>
</div>
}
</div>
</div>
@@ -2172,7 +2178,7 @@
<label class="form-label">Formula <span class="text-danger">*</span></label>
<input type="text" id="cfFormula" class="form-control font-monospace" placeholder="e.g. 2*(L*W + L*H + W*H)/144 * rate" />
<div class="form-text mt-1">
<span class="me-1">Available variables for this formula:</span>
<span class="me-1">Variables (click to insert):</span>
<span id="cfVariablePills"></span>
</div>
</div>
@@ -256,6 +256,34 @@
</div>
</div>
@* ── Kiosk PIN ─────────────────────────────────────────────── *@
<div class="d-flex align-items-center gap-2 mb-3 pb-2 border-bottom mt-4">
<h5 class="card-title mb-0">Timeclock Kiosk PIN</h5>
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Kiosk PIN"
data-bs-content="Set a 4-digit PIN so this employee can clock in and out on the shared shop tablet. Leave blank to disable kiosk access for this employee.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<label class="form-label">New PIN <span class="text-muted small">(4 digits &mdash; blank to disable)</span></label>
<div class="input-group">
<input type="password" id="kiosk-pin-input" class="form-control" maxlength="4"
placeholder="&bull;&bull;&bull;&bull;" inputmode="numeric" pattern="\d{4}"
autocomplete="new-password" />
<button type="button" class="btn btn-outline-secondary" id="btn-set-pin">
<i class="bi bi-key me-1"></i>Set PIN
</button>
<button type="button" class="btn btn-outline-danger" id="btn-clear-pin">
<i class="bi bi-x-circle me-1"></i>Clear
</button>
</div>
<div id="pin-feedback" class="form-text mt-1"></div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
@if (!string.IsNullOrEmpty(ViewBag.ReturnUrl))
{
@@ -277,6 +305,55 @@
</div>
</div>
@section Scripts {
<script>
(function () {
var userId = '@Model.Id';
var antiForgery = document.querySelector('input[name="__RequestVerificationToken"]');
var token = antiForgery ? antiForgery.value : '';
function pinPost(pin) {
var body = new URLSearchParams();
body.append('userId', userId);
if (pin !== null) body.append('pin', pin);
return fetch('/Timeclock/SetKioskPin', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: body.toString()
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Error'; });
return r.json();
});
}
document.getElementById('btn-set-pin').addEventListener('click', function () {
var val = document.getElementById('kiosk-pin-input').value.trim();
if (!/^\d{4}$/.test(val)) {
setFeedback('PIN must be exactly 4 digits.', 'danger');
return;
}
pinPost(val).then(function () {
setFeedback('PIN set successfully.', 'success');
document.getElementById('kiosk-pin-input').value = '';
}).catch(function (e) { setFeedback(e, 'danger'); });
});
document.getElementById('btn-clear-pin').addEventListener('click', function () {
if (!confirm('Clear this employee&apos;s kiosk PIN? They will no longer be able to use the tablet timeclock.')) return;
pinPost(null).then(function () {
setFeedback('PIN cleared. Employee is now disabled on the kiosk.', 'success');
}).catch(function (e) { setFeedback(e, 'danger'); });
});
function setFeedback(msg, type) {
var el = document.getElementById('pin-feedback');
el.textContent = msg;
el.className = 'form-text mt-1 text-' + type;
}
})();
</script>
}
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
@@ -205,6 +205,22 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-success bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-clock-history text-success fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Timeclock</h5>
<p class="card-text text-muted small mb-2">Track employee attendance with daily clock-in/clock-out. Multi-segment days, tablet kiosk mode with PIN, and attendance reports.</p>
<a asp-controller="Help" asp-action="Timeclock" class="btn btn-sm btn-outline-success">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reports & Admin -->
@@ -0,0 +1,173 @@
@{
ViewData["Title"] = "Timeclock";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Timeclock</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
The Timeclock module tracks facility-level employee attendance &mdash; when workers arrive and
leave the shop each day. It is separate from <em>Job Time Entries</em> (which log hours billed
against a specific job). Timeclock is about payroll and attendance; Job Time Entries are about
job costing.
</p>
<p>
Multiple clock-in/clock-out pairs per day are fully supported. Employees can clock out for
lunch, clock back in, clock out for a break, and so on. Each segment is recorded separately
with its own in/out times and duration. Daily and weekly totals are computed automatically.
</p>
<p>
Find Timeclock under <strong>Shop Floor &rsaquo; Timeclock</strong> in the left sidebar.
</p>
</section>
<section id="clocking-in-out" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-box-arrow-in-right text-success me-2"></i>Clocking In and Out
</h2>
<p>
From the Timeclock dashboard, your current status card shows whether you are clocked in or out
and how long the current segment has been running.
</p>
<ul>
<li><strong>Clock In</strong> &mdash; click the green &ldquo;Clock In&rdquo; button. Your start time is recorded immediately.</li>
<li><strong>Clock Out</strong> &mdash; click the red &ldquo;Clock Out&rdquo; button. Hours worked for that segment are calculated and saved.</li>
<li><strong>Multiple segments per day</strong> &mdash; after clocking out, just clock in again when you return. Each segment is stored separately.</li>
</ul>
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>
You cannot clock in while already clocked in. You will see an error if you try &mdash; clock out first.
</div>
</section>
<section id="whos-in" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-people-fill text-primary me-2"></i>Who&rsquo;s In
</h2>
<p>
The &ldquo;Who&rsquo;s In&rdquo; card on the dashboard shows everyone currently clocked in, with
their start time and elapsed hours. It refreshes automatically every 60&nbsp;seconds &mdash;
no manual reload needed.
</p>
</section>
<section id="kiosk" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tablet me-2"></i>Shop Floor Kiosk
</h2>
<p>
The Timeclock Kiosk is a tablet-friendly page designed to live on a shared device in the shop.
Employees do not need to log in &mdash; they tap their name tile and enter a 4-digit PIN.
The system automatically clocks them in (if currently out) or out (if currently in). A confirmation
screen shows the time, segment duration, and today&rsquo;s running total, then returns to the
employee grid after 4&nbsp;seconds.
</p>
<h5 class="fw-semibold mt-3">Activating the kiosk</h5>
<ol>
<li>On the tablet, log in as a Manager or Company Admin.</li>
<li>Navigate to <strong>Timeclock &rsaquo; Activate Kiosk</strong> (or go to <code>/Timeclock/Kiosk/Activate</code>).</li>
<li>The system generates a device token and writes a secure cookie to that browser.</li>
<li>From now on, <code>/Timeclock/Kiosk</code> works without login on that device.</li>
</ol>
<p>
If the tablet is lost or replaced, re-visit <strong>Activate Kiosk</strong> on a new device. The
old token is revoked automatically. To deactivate without re-activating, ask a Manager to clear the token from Company Settings.
</p>
<h5 class="fw-semibold mt-3">Setting employee PINs</h5>
<ol>
<li>Go to <strong>Settings &rsaquo; Users</strong> and click the employee&rsquo;s name.</li>
<li>Scroll to the <strong>Timeclock Kiosk PIN</strong> section at the bottom of the Edit User page.</li>
<li>Enter a 4-digit PIN and click <strong>Set PIN</strong>.</li>
<li>To disable an employee on the kiosk, click <strong>Clear</strong>.</li>
</ol>
<p>
Only employees with a PIN set will appear on the kiosk employee grid. Employees without a PIN
cannot use the kiosk tablet.
</p>
</section>
<section id="manager-tools" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-shield-check text-warning me-2"></i>Manager Tools
</h2>
<p>
Managers and Company Admins have additional controls on the Timeclock dashboard.
Workers cannot edit or delete their own entries &mdash; all corrections must go through a Manager or Company Admin.
</p>
<ul>
<li>
<strong>Edit an entry</strong> &mdash; click the pencil icon next to any entry in the
My Recent History table or the Team History table. Adjust clock-in time, clock-out time,
or notes. Hours worked are recalculated automatically on save.
</li>
<li>
<strong>Delete an entry</strong> &mdash; click the trash icon. The entry is soft-deleted
and excluded from all totals and reports.
</li>
<li>
<strong>Team History</strong> &mdash; the manager section at the bottom of the dashboard
lets you load any date range for all employees, with full punch detail and edit/delete controls.
</li>
</ul>
</section>
<section id="reports" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-bar-chart-line text-info me-2"></i>Attendance Report &amp; CSV Export
</h2>
<p>
Go to <strong>Reports &rsaquo; Attendance</strong> for a date-range attendance summary.
Each employee&rsquo;s card shows every daily punch segment and a day total, plus
a weekly subtotal row at the bottom of each ISO week. Managers see all employees;
workers see only their own history.
</p>
<p>
Click <strong>Export CSV (Payroll)</strong> to download a flat CSV file of the same
date range. Each row is one clock segment and includes:
</p>
<ul>
<li>Employee Name</li>
<li>Date and Day of Week</li>
<li>Clock In and Clock Out times</li>
<li>Segment Hours, Day Total Hours, and Week Total Hours</li>
<li>Notes</li>
</ul>
<p>
The CSV is designed to be imported directly into payroll software or handed off to a
payroll provider. Every row is self-contained (no merged cells or subtotal-only rows)
so it works with Excel, Google Sheets, and most payroll import wizards.
</p>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
<div class="card shadow-sm sticky-top" style="top:80px;">
<div class="card-body">
<h6 class="fw-bold mb-3">On This Page</h6>
<ul class="list-unstyled small">
<li><a href="#overview" class="text-decoration-none">Overview</a></li>
<li><a href="#clocking-in-out" class="text-decoration-none">Clocking In &amp; Out</a></li>
<li><a href="#whos-in" class="text-decoration-none">Who&rsquo;s In</a></li>
<li><a href="#kiosk" class="text-decoration-none">Shop Floor Kiosk</a></li>
<li><a href="#manager-tools" class="text-decoration-none">Manager Tools</a></li>
<li><a href="#reports" class="text-decoration-none">Attendance Report &amp; CSV</a></li>
</ul>
</div>
</div>
</div>
</div>
@@ -145,6 +145,19 @@
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">Custom Formulas</h5>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="AllowCustomFormulas" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowCustomFormulas" class="form-check-label fw-medium">Allow Custom Formula Templates</label>
</div>
<div class="form-text">
When enabled, companies on this plan can create reusable NCalc pricing formula templates
(roof curbs, enclosures, custom fabrications) and use them in quotes and jobs.
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">AI Features</h5>
<div class="mb-3">
@@ -182,6 +182,19 @@
}
</td>
</tr>
<tr>
<td class="text-muted">Custom Formulas</td>
<td>
@if (plan.AllowCustomFormulas)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
</tr>
@@ -0,0 +1,132 @@
@model List<AttendanceEmployeeRow>
@{
ViewData["Title"] = "Attendance Report";
ViewData["PageIcon"] = "bi-clock-history";
var from = (DateTime)ViewBag.From;
var to = (DateTime)ViewBag.To;
var isManager = (bool)(ViewBag.IsManager ?? false);
// ISO week number helper
int IsoWeek(DateTime d) => System.Globalization.ISOWeek.GetWeekOfYear(d);
}
<div class="row mb-3 align-items-end">
<div class="col">
<form method="get" class="d-flex align-items-center gap-2 flex-wrap">
<label class="fw-semibold">From</label>
<input type="date" name="from" class="form-control form-control-sm" style="width:150px" value="@from.ToString("yyyy-MM-dd")" />
<label class="fw-semibold">To</label>
<input type="date" name="to" class="form-control form-control-sm" style="width:150px" value="@to.ToString("yyyy-MM-dd")" />
<button type="submit" class="btn btn-sm btn-primary">
<i class="bi bi-search me-1"></i>Filter
</button>
</form>
</div>
<div class="col-auto">
<a asp-action="AttendanceCsv"
asp-route-from="@from.ToString("yyyy-MM-dd")"
asp-route-to="@to.ToString("yyyy-MM-dd")"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export CSV (Payroll)
</a>
</div>
</div>
@if (!Model.Any())
{
<div class="alert alert-info alert-permanent">
<i class="bi bi-info-circle me-2"></i>No clock entries found for the selected date range.
</div>
}
else
{
<div class="pcl-metric-strip mb-4">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "EMPLOYEES", Value: Model.Count.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL HOURS", Value: Model.Sum(r => r.TotalHours).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "DAYS COVERED", Value: ((to - from).Days + 1).ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
@foreach (var employee in Model)
{
// Group days into ISO weeks for week subtotals
var weekGroups = employee.Days
.GroupBy(d => (d.Date.Year, IsoWeek(d.Date)))
.OrderByDescending(g => g.Key)
.ToList();
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-person me-2"></i>@employee.DisplayName
</span>
<span class="badge bg-primary">@employee.TotalHours.ToString("F2") hrs total</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th class="text-end">Segment Hrs</th>
<th class="text-end">Day Total</th>
</tr>
</thead>
<tbody>
@foreach (var wg in weekGroups)
{
var weekTotal = wg.Sum(d => d.DayTotal);
// First day of week group (chronologically earliest) for label
var weekStart = wg.Min(d => d.Date);
var weekEnd = wg.Max(d => d.Date);
@foreach (var day in wg.OrderByDescending(d => d.Date))
{
var segs = day.Segments;
@for (int i = 0; i < segs.Count; i++)
{
var seg = segs[i];
<tr>
@if (i == 0)
{
<td class="fw-semibold" rowspan="@segs.Count">
@day.Date.ToString("ddd M/d/yy")
</td>
}
<td>@seg.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (seg.ClockOutTime.HasValue)
{ @seg.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt") }
else
{ <span class="badge bg-success">In Progress</span> }
</td>
<td class="text-end">@Html.Raw(seg.HoursWorked?.ToString("F2") ?? "&mdash;")</td>
@if (i == 0)
{
<td class="text-end fw-semibold" rowspan="@segs.Count">
@day.DayTotal.ToString("F2")h
</td>
}
</tr>
}
}
<tr class="table-secondary">
<td colspan="4" class="text-end text-muted small fst-italic">
Week of @weekStart.ToString("M/d") &ndash; @weekEnd.ToString("M/d")
</td>
<td class="text-end fw-bold small">@weekTotal.ToString("F2")h</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
@@ -331,6 +331,14 @@
<p>Monthly revenue, job count, and average order value trends with breakdowns by customer type and priority.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="Attendance" class="report-card">
<div class="report-card-icon" style="background:#ecfdf5;color:#059669;">
<i class="bi bi-clock-history"></i>
</div>
<h5>Attendance</h5>
<p>Daily punch detail and total hours per employee. See who clocked in, when, and how long each segment lasted.</p>
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
@@ -1270,6 +1270,10 @@ var hasReports = _isAdminOrManager || User.HasClaim("Permission", "ViewReports")
<span>Maintenance</span>
</a>
}
<a asp-controller="Timeclock" asp-action="Index" class="nav-link" data-nav="ops">
<i class="bi bi-clock-history"></i>
<span>Timeclock</span>
</a>
@* ── Reports & Templates ──────────────────────────────────── *@
@if (hasReports || hasJobs)
@@ -97,6 +97,7 @@
<tr>
<th>Plan</th>
<th class="text-center">Online Payments (Stripe Connect)</th>
<th class="text-center">Custom Formula Templates</th>
</tr>
</thead>
<tbody>
@@ -114,12 +115,23 @@
title="Toggle online payment capability for @p.DisplayName plan" />
</div>
</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block mb-0">
<input class="form-check-input plan-feature-toggle"
type="checkbox" role="switch"
data-plan-id="@p.Id"
data-feature="AllowCustomFormulas"
@(p.AllowCustomFormulas ? "checked" : "")
title="Toggle custom formula templates for @p.DisplayName plan" />
</div>
</td>
</tr>
}
</tbody>
</table>
<div class="px-3 py-2 text-muted" style="font-size:0.75rem;">
Changes take effect immediately. Companies on a plan with Online Payments enabled can connect their Stripe account in Company Settings.
Custom Formula Templates allow companies to define reusable pricing formulas in Company Settings &rarr; Custom Formulas.
</div>
</div>
</div>
@@ -0,0 +1,263 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Application.DTOs.Timeclock
@{
ViewData["Title"] = "Timeclock";
ViewData["PageIcon"] = "bi-clock-history";
var currentUser = ViewBag.CurrentUser as ApplicationUser;
var openEntry = ViewBag.OpenEntry as EmployeeClockEntryDto;
var activeEntries = (ViewBag.ActiveEntries as List<EmployeeClockEntryDto>) ?? new List<EmployeeClockEntryDto>();
var myHistory = (ViewBag.MyHistory as List<EmployeeClockEntry>) ?? new List<EmployeeClockEntry>();
var isManager = (bool)(ViewBag.IsManager ?? false);
var nowUtc = (DateTime)(ViewBag.NowUtc ?? DateTime.UtcNow);
var isClockedIn = openEntry != null;
}
@* ── Metric strip ───────────────────────────────────────────────────────────── *@
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "WHO'S IN", Value: activeEntries.Count.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@{
var todayStart = nowUtc.Date;
var myToday = myHistory.Where(e => e.ClockInTime >= todayStart);
var todayHours = myToday.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
}
@await Html.PartialAsync("_Metric", (Label: "MY HOURS TODAY", Value: Math.Round(todayHours, 1).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@{
var weekStart = nowUtc.Date.AddDays(-(int)nowUtc.DayOfWeek);
var weekHours = myHistory.Where(e => e.ClockInTime >= weekStart)
.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
}
@await Html.PartialAsync("_Metric", (Label: "MY HOURS THIS WEEK", Value: Math.Round(weekHours, 1).ToString("F1"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
<div class="row g-4">
@* ── My Clock Status ───────────────────────────────────────────────────── *@
<div class="col-12 col-lg-4">
<div class="card h-100 shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-person-badge me-2"></i>My Clock Status
</div>
<div class="card-body text-center py-4">
<div id="clock-status-display">
@if (isClockedIn)
{
<div class="mb-3">
<span class="badge bg-success fs-6 px-3 py-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
Clocked In
</span>
</div>
<p class="text-muted mb-1">Since</p>
<p class="fw-semibold mb-3">@openEntry!.ClockInTime.ToLocalTime().ToString("h:mm tt")</p>
<p class="text-muted small mb-4">Entry #@openEntry.Id</p>
}
else
{
<div class="mb-3">
<span class="badge bg-secondary fs-6 px-3 py-2">
<i class="bi bi-circle me-1" style="font-size:.5rem;vertical-align:middle;"></i>
Clocked Out
</span>
</div>
<p class="text-muted mb-4">You are not currently clocked in.</p>
}
</div>
@if (isClockedIn)
{
<button id="btn-clock-out" class="btn btn-danger btn-lg w-100"
data-entry-id="@openEntry!.Id">
<i class="bi bi-box-arrow-right me-2"></i>Clock Out
</button>
}
else
{
<button id="btn-clock-in" class="btn btn-success btn-lg w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Clock In
</button>
}
<div id="clock-feedback" class="mt-3 d-none"></div>
</div>
</div>
</div>
@* ── Who's In ───────────────────────────────────────────────────────────── *@
<div class="col-12 col-lg-8">
<div class="card h-100 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center fw-semibold">
<span><i class="bi bi-people-fill me-2"></i>Who's In <span id="whos-in-count" class="badge bg-success ms-1">@activeEntries.Count</span></span>
<small class="text-muted fw-normal" id="whos-in-updated"></small>
</div>
<div class="card-body p-0">
<div id="whos-in-container">
@await Html.PartialAsync("_WhosIn", activeEntries)
</div>
</div>
</div>
</div>
@* ── My Recent History ──────────────────────────────────────────────────── *@
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-calendar3 me-2"></i>My Recent History (Last 14 Days)
</div>
<div class="card-body p-0">
@if (!myHistory.Any())
{
<div class="text-muted text-center py-4">No clock entries in the last 14 days.</div>
}
else
{
var grouped = myHistory
.GroupBy(e => e.ClockInTime.ToLocalTime().Date)
.OrderByDescending(g => g.Key);
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Clock In</th>
<th>Clock Out</th>
<th>Hours</th>
<th>Notes</th>
@if (isManager) { <th class="text-end">Actions</th> }
</tr>
</thead>
<tbody>
@foreach (var day in grouped)
{
var dayTotal = day.Sum(e => e.HoursWorked ?? (decimal)(nowUtc - e.ClockInTime).TotalHours);
var segments = day.OrderBy(e => e.ClockInTime).ToList();
@foreach (var entry in segments)
{
<tr>
@if (entry == segments.First())
{
<td class="fw-semibold" rowspan="@segments.Count">
@day.Key.ToString("ddd M/d")
<br /><small class="text-muted">@Math.Round(dayTotal, 2)h total</small>
</td>
}
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td>
@if (entry.ClockOutTime.HasValue)
{ @entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt") }
else
{ <span class="badge bg-success">In Progress</span> }
</td>
<td>
@if (entry.HoursWorked.HasValue)
{ @entry.HoursWorked.Value.ToString("F2") }
else
{
var elapsed = (decimal)(nowUtc - entry.ClockInTime).TotalHours;
<span class="text-success">@Math.Round(elapsed, 2)h</span>
}
</td>
<td class="text-muted small">@entry.Notes</td>
@if (isManager)
{
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary btn-edit-entry"
data-id="@entry.Id"
data-clockin="@entry.ClockInTime.ToString("o")"
data-clockout="@(entry.ClockOutTime?.ToString("o") ?? "")"
data-notes="@(entry.Notes ?? "")">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-xs btn-outline-danger btn-delete-entry"
data-id="@entry.Id">
<i class="bi bi-trash"></i>
</button>
</td>
}
</tr>
}
}
</tbody>
</table>
}
</div>
</div>
</div>
@* ── Manager: All Employees / History ───────────────────────────────────── *@
@if (isManager)
{
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center fw-semibold">
<span><i class="bi bi-table me-2"></i>Team History</span>
<div class="d-flex gap-2 align-items-center">
<input type="date" id="manager-from" class="form-control form-control-sm" style="width:140px"
value="@DateTime.Today.AddDays(-7).ToString("yyyy-MM-dd")" />
<input type="date" id="manager-to" class="form-control form-control-sm" style="width:140px"
value="@DateTime.Today.ToString("yyyy-MM-dd")" />
<button id="btn-manager-load" class="btn btn-sm btn-outline-primary">
<i class="bi bi-search me-1"></i>Load
</button>
</div>
</div>
<div class="card-body p-0" id="manager-history-container">
<div class="text-muted text-center py-4 small">Select a date range and click Load.</div>
</div>
</div>
</div>
}
</div>
@* ── Edit Entry Modal ────────────────────────────────────────────────────────── *@
@if (isManager)
{
<div class="modal fade" id="editEntryModal" tabindex="-1" aria-labelledby="editEntryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editEntryModalLabel">Edit Clock Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-entry-id" />
<div class="mb-3">
<label class="form-label fw-semibold">Clock In</label>
<input type="datetime-local" id="edit-clock-in" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Clock Out</label>
<input type="datetime-local" id="edit-clock-out" class="form-control" />
<div class="form-text">Leave blank if still clocked in.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<input type="text" id="edit-notes" class="form-control" maxlength="500" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-edit">Save Changes</button>
</div>
</div>
</div>
</div>
}
@section Scripts {
<script src="~/js/timeclock.js" asp-append-version="true"></script>
<script>
Timeclock.init({
isClockedIn: @Json.Serialize(isClockedIn),
openEntryId: @Json.Serialize(openEntry?.Id),
isManager: @Json.Serialize(isManager)
});
</script>
}
@@ -0,0 +1,101 @@
@{
Layout = "_KioskLayout";
ViewData["Title"] = "Employee Timeclock";
var companyName = ViewBag.CompanyName as string ?? "Timeclock";
}
<style>
.tc-employee-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
.tc-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
padding: 1.25rem .75rem;
background: var(--bs-body-bg);
border: 2px solid var(--bs-border-color);
border-radius: 1rem;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
position: relative;
}
.tc-tile:hover { border-color: #0d6efd; box-shadow: 0 0 0 3px rgba(13,110,253,.2); }
.tc-tile.clocked-in { border-color: #198754; }
.tc-avatar {
width: 64px; height: 64px; border-radius: 50%;
background: #6c757d; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; font-weight: 700;
}
.tc-tile.clocked-in .tc-avatar { background: #198754; }
.tc-name { font-size: .9rem; font-weight: 600; text-align: center; line-height: 1.2; }
.tc-status-badge {
font-size: .7rem; padding: .15rem .5rem; border-radius: .5rem;
background: #198754; color: #fff;
}
/* PIN pad */
#tc-pin-screen { display: none; }
.tc-pin-display {
font-size: 2rem; letter-spacing: .5rem; min-height: 2.5rem;
text-align: center; font-weight: 700;
}
.tc-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; max-width: 240px; margin: 0 auto; }
.tc-key { font-size: 1.5rem; padding: .75rem; border-radius: .5rem; }
/* Confirmation */
#tc-confirm-screen { display: none; }
.tc-confirm-icon { font-size: 4rem; }
</style>
@* ── Employee Grid ────────────────────────────────────────────────────────────── *@
<div id="tc-employee-screen">
<div class="text-center mb-4">
<h4 class="fw-bold mb-0">@companyName &mdash; Timeclock</h4>
<p class="text-muted small">Tap your name to clock in or out</p>
</div>
<div id="tc-employee-grid" class="tc-employee-grid">
<div class="text-center py-4 text-muted">Loading&hellip;</div>
</div>
</div>
@* ── PIN Entry Screen ─────────────────────────────────────────────────────────── *@
<div id="tc-pin-screen">
<div class="text-center mb-4">
<h5 id="tc-pin-name" class="fw-bold"></h5>
<p class="text-muted small">Enter your 4-digit PIN</p>
</div>
<div class="tc-pin-display mb-3" id="tc-pin-display">&bull;&bull;&bull;&bull;</div>
<div id="tc-pin-error" class="alert alert-danger text-center d-none mb-3"></div>
<div class="tc-keypad mb-4">
@foreach (var n in new[]{"1","2","3","4","5","6","7","8","9","&larr;","0","&check;"})
{
var cls = n switch {
"&larr;" => "btn btn-warning tc-key",
"&check;" => "btn btn-success tc-key",
_ => "btn btn-outline-secondary tc-key"
};
<button class="@cls tc-keypad-btn" data-key="@n">@Html.Raw(n)</button>
}
</div>
<div class="text-center">
<button class="btn btn-link text-muted" id="tc-back-btn">&larr; Back to employees</button>
</div>
</div>
@* ── Confirmation Screen ──────────────────────────────────────────────────────── *@
<div id="tc-confirm-screen" class="text-center py-3">
<div id="tc-confirm-icon" class="tc-confirm-icon mb-3"></div>
<h4 id="tc-confirm-title" class="fw-bold mb-1"></h4>
<p id="tc-confirm-time" class="text-muted mb-2"></p>
<p id="tc-confirm-today" class="fw-semibold"></p>
<p class="text-muted small mt-3" id="tc-dismiss-msg">Returning to employee list in <span id="tc-countdown">4</span>s&hellip;</p>
</div>
@section Scripts {
<script src="~/js/timeclock-kiosk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,12 @@
@model string
@{
Layout = "~/Views/Shared/_KioskLayout.cshtml";
ViewData["Title"] = "Unable to Start";
}
<div class="text-center py-5">
<i class="bi bi-exclamation-triangle-fill text-warning" style="font-size:4rem;"></i>
<h2 class="mt-3 fw-bold">Timeclock Unavailable</h2>
<p class="text-muted mt-2">@Html.Raw(Model)</p>
<p class="mt-4 text-muted" style="font-size:.9rem;">Please ask a manager for assistance.</p>
</div>
@@ -0,0 +1,32 @@
@model List<PowderCoating.Application.DTOs.Timeclock.EmployeeClockEntryDto>
@{
var nowUtc = DateTime.UtcNow;
}
@if (!Model.Any())
{
<div class="text-muted text-center py-4">No employees currently clocked in.</div>
}
else
{
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Employee</th>
<th>Clocked In Since</th>
<th>Elapsed</th>
</tr>
</thead>
<tbody>
@foreach (var entry in Model)
{
var elapsed = (nowUtc - entry.ClockInTime).TotalHours;
<tr>
<td class="fw-semibold">@entry.UserDisplayName</td>
<td>@entry.ClockInTime.ToLocalTime().ToString("h:mm tt")</td>
<td><span class="text-success">@Math.Round(elapsed, 1)h</span></td>
</tr>
}
</tbody>
</table>
}
@@ -142,9 +142,17 @@
if (key === 'name') { cfValidateFieldNameInput(i, val); cfRenderVariablePills(); }
};
const CF_SHOP_RATE_VARS = [
{ name: 'standard_labor_rate', title: 'StandardLaborRate — your billing rate in $/hr' },
{ name: 'additional_coat_labor_pct', title: 'AdditionalCoatLaborPercent (0100)' },
{ name: 'markup_pct', title: 'GeneralMarkupPercentage (0100)' },
];
function cfValidateFieldName(name) {
if (!name) return 'Field variable name is required.';
if (name === 'rate') return '"rate" is reserved — it is pre-populated from the template\'s Default Rate.';
if (CF_SHOP_RATE_VARS.some(v => v.name === name))
return `"${name}" is a built-in shop rate variable and cannot be used as a field name.`;
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) return 'Must start with a letter and contain only letters, digits, or underscores (no spaces).';
return null;
}
@@ -211,11 +219,15 @@
const container = document.getElementById('cfVariablePills');
if (!container) return;
const names = cfFields.map(f => f.name).filter(n => n && !cfValidateFieldName(n));
const all = [...names, 'rate'];
container.innerHTML = all.map(n =>
const userPills = [...names, 'rate'].map(n =>
`<span class="badge bg-secondary me-1 mb-1" style="cursor:pointer; font-size:.75rem;"
title="Click to insert into formula" onclick="cfInsertVariable('${escHtml(n)}')">${escHtml(n)}</span>`
).join('');
const shopPills = CF_SHOP_RATE_VARS.map(v =>
`<span class="badge bg-info text-dark me-1 mb-1" style="cursor:pointer; font-size:.75rem;"
title="${escHtml(v.title)}" onclick="cfInsertVariable('${escHtml(v.name)}')">${escHtml(v.name)}</span>`
).join('');
container.innerHTML = userPills + (shopPills ? `<span class="text-muted small me-1">| shop:</span>${shopPills}` : '');
}
window.cfInsertVariable = function (name) {
@@ -566,10 +578,16 @@
</ul>
</div>
</div>
<div class="alert alert-info mb-0 small">
<div class="alert alert-info mb-2 small">
<i class="bi bi-info-circle me-1"></i>
<code>rate</code> is always available &mdash; you set it as the <strong>Default Rate</strong> on the template
and staff can override it per-use. Don&rsquo;t create a field called <code>rate</code>.
</div>
<div class="alert alert-secondary mb-0 small">
<i class="bi bi-shop me-1"></i>
Three <strong>shop rate variables</strong> are injected automatically from your Company Settings:
<code>standard_labor_rate</code> ($/hr), <code>additional_coat_labor_pct</code> (0&ndash;100),
<code>markup_pct</code> (0&ndash;100). Click their teal pills to insert them &mdash; no need to declare them as fields.
</div>`
},
{
@@ -0,0 +1,167 @@
/* timeclock-kiosk.js — tablet kiosk PIN flow */
(function () {
var employeeScreen = document.getElementById('tc-employee-screen');
var pinScreen = document.getElementById('tc-pin-screen');
var confirmScreen = document.getElementById('tc-confirm-screen');
var selectedUserId = null;
var pinBuffer = '';
var countdownTimer = null;
// ── Bootstrap: load employees ──────────────────────────────────────────
loadEmployees();
setInterval(loadEmployees, 30000); // refresh tile states every 30 s
function loadEmployees() {
fetch('/Timeclock/KioskEmployees')
.then(function (r) { return r.json(); })
.then(function (employees) { renderEmployees(employees); });
}
function renderEmployees(employees) {
var grid = document.getElementById('tc-employee-grid');
if (!employees.length) {
grid.innerHTML = '<div class="text-muted text-center py-4 col-12">No employees have kiosk PINs set.</div>';
return;
}
grid.innerHTML = employees.map(function (emp) {
var inClass = emp.isClockedIn ? ' clocked-in' : '';
var badge = emp.isClockedIn ? '<span class="tc-status-badge">In</span>' : '';
return '<button class="tc-tile' + inClass + '" data-user-id="' + escHtml(emp.userId) + '" data-name="' + escHtml(emp.displayName) + '">' +
'<div class="tc-avatar">' + escHtml(emp.initials) + '</div>' +
'<span class="tc-name">' + escHtml(emp.displayName) + '</span>' +
badge +
'</button>';
}).join('');
grid.querySelectorAll('.tc-tile').forEach(function (tile) {
tile.addEventListener('click', function () {
openPinScreen(tile.getAttribute('data-user-id'), tile.getAttribute('data-name'));
});
});
}
// ── PIN screen ──────────────────────────────────────────────────────────
function openPinScreen(userId, name) {
selectedUserId = userId;
pinBuffer = '';
renderPinDisplay();
document.getElementById('tc-pin-name').textContent = name;
document.getElementById('tc-pin-error').classList.add('d-none');
showScreen(pinScreen);
}
document.querySelectorAll('.tc-keypad-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var key = btn.getAttribute('data-key');
handleKey(key);
});
});
document.getElementById('tc-back-btn').addEventListener('click', function () {
showScreen(employeeScreen);
});
function handleKey(key) {
if (key === '&larr;') {
pinBuffer = pinBuffer.slice(0, -1);
renderPinDisplay();
} else if (key === '&check;') {
if (pinBuffer.length === 4) submitPin();
} else if (/^\d$/.test(key)) {
if (pinBuffer.length < 4) {
pinBuffer += key;
renderPinDisplay();
if (pinBuffer.length === 4) submitPin();
}
}
}
function renderPinDisplay() {
var filled = pinBuffer.length;
var dots = '';
for (var i = 0; i < 4; i++) {
dots += filled > i ? '&#9679;' : '&#9675;';
if (i < 3) dots += '&nbsp;';
}
document.getElementById('tc-pin-display').innerHTML = dots;
}
function submitPin() {
document.getElementById('tc-pin-error').classList.add('d-none');
fetch('/Timeclock/KioskPunch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: selectedUserId, pin: pinBuffer })
})
.then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Error'; });
return r.json();
})
.then(function (result) {
showConfirmation(result);
})
.catch(function (msg) {
pinBuffer = '';
renderPinDisplay();
var err = document.getElementById('tc-pin-error');
err.textContent = msg;
err.classList.remove('d-none');
});
}
// ── Confirmation screen ─────────────────────────────────────────────────
function showConfirmation(result) {
var isIn = result.action === 'clockIn';
var icon = isIn ? '&#x2705;' : '&#x1F44B;';
var title = isIn ? 'Clocked In' : 'Clocked Out';
var timeStr = new Date(result.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var todayLine = result.dailyTotal.toFixed(2) + ' hrs today (' + result.segmentCount + (result.segmentCount === 1 ? ' segment' : ' segments') + ')';
document.getElementById('tc-confirm-icon').innerHTML = icon;
document.getElementById('tc-confirm-title').textContent = result.displayName + ' &mdash; ' + title;
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' &mdash; ' + title;
document.getElementById('tc-confirm-time').textContent = timeStr;
document.getElementById('tc-confirm-today').textContent = todayLine;
showScreen(confirmScreen);
startCountdown(4);
}
function startCountdown(seconds) {
if (countdownTimer) clearInterval(countdownTimer);
var remaining = seconds;
var el = document.getElementById('tc-countdown');
el.textContent = remaining;
countdownTimer = setInterval(function () {
remaining--;
el.textContent = remaining;
if (remaining <= 0) {
clearInterval(countdownTimer);
countdownTimer = null;
loadEmployees();
showScreen(employeeScreen);
}
}, 1000);
}
// ── Utilities ───────────────────────────────────────────────────────────
function showScreen(screen) {
[employeeScreen, pinScreen, confirmScreen].forEach(function (s) {
s.style.display = s === screen ? '' : 'none';
});
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
@@ -0,0 +1,192 @@
/* timeclock.js — main-app timeclock dashboard */
var Timeclock = (function () {
var _cfg = {};
function init(cfg) {
_cfg = cfg;
bindClockButtons();
bindManagerEvents();
scheduleWhosInRefresh();
}
/* ── Clock In / Out ────────────────────────────────────────────────────── */
function bindClockButtons() {
var btnIn = document.getElementById('btn-clock-in');
var btnOut = document.getElementById('btn-clock-out');
if (btnIn) {
btnIn.addEventListener('click', function () {
btnIn.disabled = true;
postJson('/Timeclock/ClockIn', {})
.then(function (r) { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnIn.disabled = false;
});
});
}
if (btnOut) {
btnOut.addEventListener('click', function () {
btnOut.disabled = true;
var entryId = parseInt(btnOut.getAttribute('data-entry-id'));
postJson('/Timeclock/ClockOut', { entryId: entryId })
.then(function (r) { location.reload(); })
.catch(function (err) {
showFeedback(err, 'danger');
btnOut.disabled = false;
});
});
}
}
/* ── Who's In refresh every 60 s ──────────────────────────────────────── */
function scheduleWhosInRefresh() {
setInterval(refreshWhosIn, 60000);
}
function refreshWhosIn() {
fetch('/Timeclock/ActiveNow')
.then(function (r) { return r.json(); })
.then(function (data) {
var count = data.length;
var badge = document.getElementById('whos-in-count');
if (badge) badge.textContent = count;
var ts = document.getElementById('whos-in-updated');
if (ts) ts.textContent = 'Updated ' + new Date().toLocaleTimeString();
});
}
/* ── Manager: edit / delete ────────────────────────────────────────────── */
function bindManagerEvents() {
if (!_cfg.isManager) return;
document.addEventListener('click', function (e) {
if (e.target.closest('.btn-edit-entry')) openEditModal(e.target.closest('.btn-edit-entry'));
if (e.target.closest('.btn-delete-entry')) confirmDelete(e.target.closest('.btn-delete-entry'));
});
var btnSaveEdit = document.getElementById('btn-save-edit');
if (btnSaveEdit) btnSaveEdit.addEventListener('click', saveEdit);
var btnLoad = document.getElementById('btn-manager-load');
if (btnLoad) btnLoad.addEventListener('click', loadManagerHistory);
}
function openEditModal(btn) {
document.getElementById('edit-entry-id').value = btn.getAttribute('data-id');
document.getElementById('edit-clock-in').value = toDatetimeLocal(btn.getAttribute('data-clockin'));
var out = btn.getAttribute('data-clockout');
document.getElementById('edit-clock-out').value = out ? toDatetimeLocal(out) : '';
document.getElementById('edit-notes').value = btn.getAttribute('data-notes');
new bootstrap.Modal(document.getElementById('editEntryModal')).show();
}
function saveEdit() {
var id = parseInt(document.getElementById('edit-entry-id').value);
var inVal = document.getElementById('edit-clock-in').value;
var outVal = document.getElementById('edit-clock-out').value;
var notes = document.getElementById('edit-notes').value;
postJson('/Timeclock/Edit', {
id: id,
clockInTime: new Date(inVal).toISOString(),
clockOutTime: outVal ? new Date(outVal).toISOString() : null,
notes: notes
}).then(function () {
location.reload();
}).catch(function (err) {
alert('Error saving: ' + err);
});
}
function confirmDelete(btn) {
if (!confirm('Delete this clock entry? This cannot be undone.')) return;
var id = parseInt(btn.getAttribute('data-id'));
postJson('/Timeclock/Delete', id)
.then(function () { location.reload(); })
.catch(function (err) { alert('Error: ' + err); });
}
function loadManagerHistory() {
var from = document.getElementById('manager-from').value;
var to = document.getElementById('manager-to').value;
var url = '/Timeclock/History?from=' + from + '&to=' + to;
fetch(url)
.then(function (r) { return r.json(); })
.then(function (data) {
renderManagerHistory(data);
});
}
function renderManagerHistory(entries) {
var container = document.getElementById('manager-history-container');
if (!entries.length) {
container.innerHTML = '<div class="text-muted text-center py-4">No entries in this range.</div>';
return;
}
var rows = entries.map(function (e) {
var inTime = new Date(e.clockInTime).toLocaleString();
var outTime = e.clockOutTime ? new Date(e.clockOutTime).toLocaleString() : '&mdash;';
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '&mdash;';
return '<tr>' +
'<td>' + escHtml(e.userDisplayName) + '</td>' +
'<td>' + inTime + '</td>' +
'<td>' + outTime + '</td>' +
'<td>' + hours + '</td>' +
'<td>' + escHtml(e.notes || '') + '</td>' +
'</tr>';
}).join('');
container.innerHTML = '<table class="table table-hover table-sm mb-0">' +
'<thead class="table-light"><tr>' +
'<th>Employee</th><th>Clock In</th><th>Clock Out</th><th>Hours</th><th>Notes</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
/* ── Utilities ─────────────────────────────────────────────────────────── */
function postJson(url, data) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': getAntiForgery() },
body: JSON.stringify(data)
}).then(function (r) {
if (!r.ok) return r.json().then(function (e) { throw e.message || 'Request failed'; });
return r.json();
});
}
function getAntiForgery() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
function showFeedback(msg, type) {
var el = document.getElementById('clock-feedback');
if (!el) return;
el.className = 'mt-3 alert alert-' + type + ' alert-permanent';
el.textContent = msg;
el.classList.remove('d-none');
}
function toDatetimeLocal(isoStr) {
if (!isoStr) return '';
var d = new Date(isoStr);
var pad = function (n) { return String(n).padStart(2, '0'); };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init: init };
})();