Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/PlatformSubscriptionController.cs
T
spouliot 6c2fe6e1c4 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>
2026-05-26 19:53:13 -04:00

168 lines
7.3 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Subscription;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only interface for managing subscription plan configurations
/// (limits, pricing, feature flags, and Stripe price IDs). Changes here
/// affect every new tenant assignment of the plan and any quota checks that
/// read plan limits at runtime. Plan <c>DisplayName</c> and <c>Plan</c> enum
/// value are intentionally not editable here to prevent breaking existing company
/// subscription records that reference them by integer value.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformSubscriptionController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PlatformSubscriptionController> _logger;
public PlatformSubscriptionController(IUnitOfWork unitOfWork, ILogger<PlatformSubscriptionController> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Lists all subscription plan configurations ordered by display sort order.
/// Uses <c>ignoreQueryFilters: true</c> because plan configs carry
/// <c>CompanyId = 0</c> and are otherwise hidden by the multi-tenancy filter.
/// Projects to <see cref="SubscriptionPlanConfigDto"/> to avoid exposing the
/// full entity (including internal Stripe secrets) to the view layer.
/// </summary>
[HttpGet]
public async Task<IActionResult> Index()
{
var configs = (await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync(ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
.ToList();
var dtos = configs.Select(c => new SubscriptionPlanConfigDto
{
Id = c.Id,
Plan = c.Plan,
DisplayName = c.DisplayName,
Description = c.Description,
MaxUsers = c.MaxUsers,
MaxActiveJobs = c.MaxActiveJobs,
MaxCustomers = c.MaxCustomers,
MaxQuotes = c.MaxQuotes,
MaxCatalogItems = c.MaxCatalogItems,
MaxJobPhotos = c.MaxJobPhotos,
MaxQuotePhotos = c.MaxQuotePhotos,
MaxAiPhotoQuotesPerMonth = c.MaxAiPhotoQuotesPerMonth,
MonthlyPrice = c.MonthlyPrice,
AnnualPrice = c.AnnualPrice,
StripePriceIdMonthly = c.StripePriceIdMonthly,
StripePriceIdAnnual = c.StripePriceIdAnnual,
AllowOnlinePayments = c.AllowOnlinePayments,
AllowAccounting = c.AllowAccounting,
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
AllowSms = c.AllowSms,
AllowCustomFormulas = c.AllowCustomFormulas,
IsActive = c.IsActive,
SortOrder = c.SortOrder
}).ToList();
return View(dtos);
}
/// <summary>
/// Returns the Edit form for a plan config, loaded into an
/// <see cref="UpdateSubscriptionPlanConfigDto"/> to prevent over-posting of
/// immutable fields such as <c>Plan</c>, <c>DisplayName</c>, and <c>SortOrder</c>.
/// The plan display name is placed in ViewBag for the page heading.
/// </summary>
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
if (config == null) return NotFound();
var dto = new UpdateSubscriptionPlanConfigDto
{
Id = config.Id,
Description = config.Description,
MaxUsers = config.MaxUsers,
MaxActiveJobs = config.MaxActiveJobs,
MaxCustomers = config.MaxCustomers,
MaxQuotes = config.MaxQuotes,
MaxCatalogItems = config.MaxCatalogItems,
MaxJobPhotos = config.MaxJobPhotos,
MaxQuotePhotos = config.MaxQuotePhotos,
MaxAiPhotoQuotesPerMonth = config.MaxAiPhotoQuotesPerMonth,
MonthlyPrice = config.MonthlyPrice,
AnnualPrice = config.AnnualPrice,
StripePriceIdMonthly = config.StripePriceIdMonthly,
StripePriceIdAnnual = config.StripePriceIdAnnual,
AllowOnlinePayments = config.AllowOnlinePayments,
AllowAccounting = config.AllowAccounting,
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
AllowSms = config.AllowSms,
AllowCustomFormulas = config.AllowCustomFormulas,
IsActive = config.IsActive
};
ViewBag.PlanName = config.DisplayName;
return View(dto);
}
/// <summary>
/// Applies the updated plan configuration values and saves. Uses explicit field
/// mapping from <paramref name="dto"/> to the tracked entity so that immutable
/// identity fields (<c>Plan</c>, <c>DisplayName</c>, <c>SortOrder</c>) are never
/// overwritten. Logs the change at Information level for the audit trail.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateSubscriptionPlanConfigDto dto)
{
if (!ModelState.IsValid)
{
var configForView = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
ViewBag.PlanName = configForView?.DisplayName ?? "Unknown";
return View(dto);
}
var config = await _unitOfWork.SubscriptionPlanConfigs.GetByIdAsync(id, ignoreQueryFilters: true);
if (config == null) return NotFound();
config.Description = dto.Description;
config.MaxUsers = dto.MaxUsers;
config.MaxActiveJobs = dto.MaxActiveJobs;
config.MaxCustomers = dto.MaxCustomers;
config.MaxQuotes = dto.MaxQuotes;
config.MaxCatalogItems = dto.MaxCatalogItems;
config.MaxJobPhotos = dto.MaxJobPhotos;
config.MaxQuotePhotos = dto.MaxQuotePhotos;
config.MaxAiPhotoQuotesPerMonth = dto.MaxAiPhotoQuotesPerMonth;
config.MonthlyPrice = dto.MonthlyPrice;
config.AnnualPrice = dto.AnnualPrice;
config.StripePriceIdMonthly = dto.StripePriceIdMonthly;
config.StripePriceIdAnnual = dto.StripePriceIdAnnual;
config.AllowOnlinePayments = dto.AllowOnlinePayments;
config.AllowAccounting = dto.AllowAccounting;
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
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);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SuperAdmin updated subscription plan config: {Plan}", config.DisplayName);
TempData["Success"] = $"{config.DisplayName} plan configuration updated successfully.";
return RedirectToAction(nameof(Index));
}
}