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:
@@ -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)
|
||||
|
||||
Generated
+10854
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10857
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 0–100 (e.g. cost * (1 + additional_coat_labor_pct/100))
|
||||
markup_pct — general markup percentage 0–100 (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 › 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
|
||||
""";
|
||||
|
||||
}
|
||||
|
||||
@@ -93,9 +93,10 @@ public class SubscriptionMiddleware
|
||||
// SuperAdmins get all features but still need the feature flags set for views
|
||||
if (context.User.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||
{
|
||||
context.Items["AllowOnlinePayments"] = true;
|
||||
context.Items["AllowAccounting"] = true;
|
||||
context.Items["AllowSms"] = true;
|
||||
context.Items["AllowOnlinePayments"] = true;
|
||||
context.Items["AllowAccounting"] = true;
|
||||
context.Items["AllowSms"] = true;
|
||||
context.Items["AllowCustomFormulas"] = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
@@ -143,8 +144,10 @@ public class SubscriptionMiddleware
|
||||
context.Items["AllowAccounting"] = company.IsComped
|
||||
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
|
||||
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
|
||||
context.Items["AllowSms"] = company.IsComped
|
||||
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 — blank to disable)</span></label>
|
||||
<div class="input-group">
|
||||
<input type="password" id="kiosk-pin-input" class="form-control" maxlength="4"
|
||||
placeholder="••••" 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'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 — 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 › 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> — click the green “Clock In” button. Your start time is recorded immediately.</li>
|
||||
<li><strong>Clock Out</strong> — click the red “Clock Out” button. Hours worked for that segment are calculated and saved.</li>
|
||||
<li><strong>Multiple segments per day</strong> — after clocking out, just clock in again when you return. Each segment is stored separately.</li>
|
||||
</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 — 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’s In
|
||||
</h2>
|
||||
<p>
|
||||
The “Who’s In” card on the dashboard shows everyone currently clocked in, with
|
||||
their start time and elapsed hours. It refreshes automatically every 60 seconds —
|
||||
no manual reload needed.
|
||||
</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 — they tap their name tile and enter a 4-digit PIN.
|
||||
The system automatically clocks them in (if currently out) or out (if currently in). A confirmation
|
||||
screen shows the time, segment duration, and today’s running total, then returns to the
|
||||
employee grid after 4 seconds.
|
||||
</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 › 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 › Users</strong> and click the employee’s name.</li>
|
||||
<li>Scroll to the <strong>Timeclock Kiosk PIN</strong> section at the bottom of the Edit User page.</li>
|
||||
<li>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 — all corrections must go through a Manager or Company Admin.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Edit an entry</strong> — click the pencil icon next to any entry in the
|
||||
My Recent History table or the Team History table. Adjust clock-in time, clock-out time,
|
||||
or notes. Hours worked are recalculated automatically on save.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Delete an entry</strong> — click the trash icon. The entry is soft-deleted
|
||||
and excluded from all totals and reports.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Team History</strong> — the manager section at the bottom of the dashboard
|
||||
lets you load any date range for all employees, with full punch detail and edit/delete controls.
|
||||
</li>
|
||||
</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 & CSV Export
|
||||
</h2>
|
||||
<p>
|
||||
Go to <strong>Reports › Attendance</strong> for a date-range attendance summary.
|
||||
Each employee’s card shows every daily punch segment and a day total, plus
|
||||
a weekly subtotal row at the bottom of each ISO week. Managers see all employees;
|
||||
workers see only their own history.
|
||||
</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 & Out</a></li>
|
||||
<li><a href="#whos-in" class="text-decoration-none">Who’s In</a></li>
|
||||
<li><a href="#kiosk" class="text-decoration-none">Shop Floor Kiosk</a></li>
|
||||
<li><a href="#manager-tools" class="text-decoration-none">Manager Tools</a></li>
|
||||
<li><a href="#reports" class="text-decoration-none">Attendance Report & 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") ?? "—")</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") – @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 → 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 — 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…</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">••••</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","←","0","✓"})
|
||||
{
|
||||
var cls = n switch {
|
||||
"←" => "btn btn-warning tc-key",
|
||||
"✓" => "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">← 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…</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 (0–100)' },
|
||||
{ name: 'markup_pct', title: 'GeneralMarkupPercentage (0–100)' },
|
||||
];
|
||||
|
||||
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 — you set it as the <strong>Default Rate</strong> on the template
|
||||
and staff can override it per-use. Don’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–100),
|
||||
<code>markup_pct</code> (0–100). Click their teal pills to insert them — 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 === '←') {
|
||||
pinBuffer = pinBuffer.slice(0, -1);
|
||||
renderPinDisplay();
|
||||
} else if (key === '✓') {
|
||||
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 ? '●' : '○';
|
||||
if (i < 3) dots += ' ';
|
||||
}
|
||||
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 ? '✅' : '👋';
|
||||
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 + ' — ' + title;
|
||||
document.getElementById('tc-confirm-title').innerHTML = escHtml(result.displayName) + ' — ' + 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
})();
|
||||
@@ -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() : '—';
|
||||
var hours = e.hoursWorked != null ? e.hoursWorked.toFixed(2) : '—';
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
})();
|
||||
Reference in New Issue
Block a user