Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries) - KioskPin added to ApplicationUser; TimeclockKioskToken added to Company - TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete, tablet kiosk with device-cookie auth, PIN management via Users edit page - Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out - Attendance report at /Reports/Attendance with weekly subtotal rows - Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment) - AllowCustomFormulas wired through PlatformSubscriptionController + subscription views - Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating) - Help article (Help/Timeclock.cshtml) and AI knowledge base updated - Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,7 @@ public class CompanySettingsController : Controller
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||
dto.AllowSms = planConfig?.AllowSms ?? false;
|
||||
ViewBag.AllowCustomFormulas = AllowCustomFormulas();
|
||||
dto.SmsEnabled = company.SmsEnabled;
|
||||
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
||||
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
||||
@@ -2971,10 +2972,13 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ─── Custom Formula Item Templates ──────────────────────────────────────────
|
||||
|
||||
private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||
|
||||
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomItemTemplate(int id)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -2987,6 +2991,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomItemTemplates()
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||
t => t.CompanyId == companyId);
|
||||
@@ -2998,6 +3003,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
@@ -3019,6 +3025,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
@@ -3041,6 +3048,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -3059,6 +3067,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -3104,17 +3113,57 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a NCalc formula with the supplied variable values.
|
||||
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
|
||||
/// in the Application/Infrastructure layer.
|
||||
/// Evaluates a NCalc formula with the supplied variable values, automatically injecting
|
||||
/// three read-only shop-rate variables sourced from the company's operating costs:
|
||||
/// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
|
||||
/// User-supplied variables take precedence so the test panel can override them.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||
public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||
|
||||
// Inject shop-rate system variables; user-supplied values win if the same key appears in both.
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId != null)
|
||||
{
|
||||
var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
|
||||
if (costs != null)
|
||||
req = InjectShopRateVariables(req, costs);
|
||||
}
|
||||
|
||||
var result = _formulaAiService.EvaluateFormula(req);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
|
||||
/// from <paramref name="costs"/> into the request's variable map without overwriting any key
|
||||
/// the caller already set (so the test panel can still override these values explicitly).
|
||||
/// </summary>
|
||||
private static EvaluateFormulaRequest InjectShopRateVariables(
|
||||
EvaluateFormulaRequest req, CompanyOperatingCosts costs)
|
||||
{
|
||||
var vars = System.Text.Json.JsonSerializer
|
||||
.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();
|
||||
|
||||
void Inject(string key, decimal value)
|
||||
{
|
||||
if (!vars.ContainsKey(key))
|
||||
vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
|
||||
}
|
||||
|
||||
Inject("standard_labor_rate", costs.StandardLaborRate);
|
||||
Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
|
||||
Inject("markup_pct", costs.GeneralMarkupPercentage);
|
||||
|
||||
return new EvaluateFormulaRequest
|
||||
{
|
||||
Formula = req.Formula,
|
||||
VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls Claude to generate a formula template from a natural-language description
|
||||
/// and an optional diagram image uploaded in the same multipart form.
|
||||
@@ -3122,6 +3171,7 @@ public class CompanySettingsController : Controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
|
||||
{
|
||||
if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
return Json(new { success = false, error = "Description is required." });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user