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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user