90f333c8f3
Fix Razor rendering of TermsVersion — property chains after a literal character need @() parentheses or Razor misparses the expression. Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove stale template rows (no longer canonical, never customised) on next settings visit, so retired types like JobReadyForPickup SMS disappear automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3017 lines
134 KiB
C#
3017 lines
134 KiB
C#
using AutoMapper;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Application.DTOs.Company;
|
|
using PowderCoating.Application.DTOs.Lookup;
|
|
using PowderCoating.Application.DTOs.Notification;
|
|
using PowderCoating.Application.DTOs.PrepService;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Application.Services;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Core.Interfaces.Services;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
using System.Security.Claims;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public class CompanySettingsController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IMapper _mapper;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly ILogger<CompanySettingsController> _logger;
|
|
private readonly ICompanyLogoService _logoService;
|
|
private readonly ILookupCacheService _lookupCache;
|
|
private readonly IStripeConnectService _stripeConnect;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly IAuditLogService _auditLog;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
|
|
|
public CompanySettingsController(
|
|
IUnitOfWork unitOfWork,
|
|
IMapper mapper,
|
|
ITenantContext tenantContext,
|
|
ILogger<CompanySettingsController> logger,
|
|
ICompanyLogoService logoService,
|
|
ILookupCacheService lookupCache,
|
|
IStripeConnectService stripeConnect,
|
|
IConfiguration configuration,
|
|
IAuditLogService auditLog,
|
|
UserManager<ApplicationUser> userManager,
|
|
SignInManager<ApplicationUser> signInManager)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_tenantContext = tenantContext;
|
|
_logger = logger;
|
|
_logoService = logoService;
|
|
_lookupCache = lookupCache;
|
|
_stripeConnect = stripeConnect;
|
|
_configuration = configuration;
|
|
_auditLog = auditLog;
|
|
_userManager = userManager;
|
|
_signInManager = signInManager;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostic endpoint that dumps the current user's claims as JSON. Used during development and
|
|
/// support investigations to verify that Identity claims (CompanyId, CompanyRole, system role) are
|
|
/// correctly populated after login. Restricted to SuperAdmin so it is never exposed to tenant users.
|
|
/// </summary>
|
|
// GET: CompanySettings/TestClaims - Debug endpoint (SuperAdmin only)
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public IActionResult TestClaims()
|
|
{
|
|
var claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList();
|
|
return Json(new
|
|
{
|
|
isAuthenticated = User.Identity?.IsAuthenticated,
|
|
userName = User.Identity?.Name,
|
|
claims = claims,
|
|
companyId = User.FindFirst("CompanyId")?.Value,
|
|
companyRole = User.FindFirst("CompanyRole")?.Value
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the Company Settings hub page with all configuration tabs pre-loaded.
|
|
/// Resolves the tenant from <see cref="ITenantContext"/> — if the claim is missing or malformed
|
|
/// (e.g. Base-64 decode failure after a cookie format change) the user is bounced to Home rather
|
|
/// than shown a hard error. Notification templates are lazily seeded on first visit via
|
|
/// <see cref="EnsureNotificationTemplatesSeededAsync"/> so the database never starts empty.
|
|
/// The <c>AllowOnlinePayments</c> flag is read from <c>SubscriptionPlanConfig</c> (not hardcoded)
|
|
/// so plan changes take effect without a deployment.
|
|
/// </summary>
|
|
// GET: CompanySettings
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
try
|
|
{
|
|
// Log user information for debugging
|
|
var userName = User.Identity?.Name;
|
|
var companyRole = User.FindFirst("CompanyRole")?.Value;
|
|
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
|
|
|
|
_logger.LogInformation("CompanySettings accessed by user: {UserName}, CompanyRole: {CompanyRole}, CompanyIdClaim: {CompanyIdClaim}",
|
|
userName, companyRole, companyIdClaim);
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
{
|
|
_logger.LogWarning("User {UserName} does not have a company ID. CompanyRole: {CompanyRole}", userName, companyRole);
|
|
TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
|
|
return RedirectToAction("Index", "Home");
|
|
}
|
|
|
|
// Load company with operating costs and preferences
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(
|
|
companyId.Value,
|
|
false,
|
|
c => c.OperatingCosts,
|
|
c => c.Preferences);
|
|
|
|
if (company == null)
|
|
{
|
|
_logger.LogWarning("Company {CompanyId} not found for user {UserName}", companyId, userName);
|
|
TempData["ErrorMessage"] = "Company not found.";
|
|
return RedirectToAction("Index", "Home");
|
|
}
|
|
|
|
var dto = _mapper.Map<CompanySettingsDto>(company);
|
|
|
|
// Populate plan-gated feature flags
|
|
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
|
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
|
dto.AllowSms = planConfig?.AllowSms ?? false;
|
|
dto.SmsEnabled = company.SmsEnabled;
|
|
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
|
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
|
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
|
|
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
|
|
|
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
|
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
|
|
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
|
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Load notification templates for inline tab
|
|
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
|
if (seeded > 0)
|
|
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
|
|
|
dto.NotificationTemplates = existing
|
|
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
|
.Select(t => new NotificationTemplateDto
|
|
{
|
|
Id = t.Id,
|
|
NotificationType = t.NotificationType,
|
|
Channel = t.Channel,
|
|
DisplayName = t.DisplayName,
|
|
Subject = t.Subject,
|
|
Body = t.Body,
|
|
UpdatedAt = t.UpdatedAt
|
|
}).ToList();
|
|
|
|
return View(dto);
|
|
}
|
|
catch (FormatException fex)
|
|
{
|
|
_logger.LogError(fex, "Format exception in CompanySettings - likely Base-64 decode issue");
|
|
TempData["ErrorMessage"] = "An authentication error occurred. Please try logging out and logging back in.";
|
|
return RedirectToAction("Index", "Home");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading company settings for user {UserName}", User.Identity?.Name);
|
|
TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
|
|
return RedirectToAction("Index", "Home");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists basic company profile fields (name, address, contact info, timezone) from an AJAX call.
|
|
/// Uses AutoMapper to apply only the mapped fields so unrelated Company properties
|
|
/// (logo, subscription plan, Stripe keys) are never accidentally overwritten.
|
|
/// Returns a JSON envelope so the view can display an inline success/error toast without a full reload.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateCompanyInfo
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateCompanyInfo([FromBody] UpdateCompanySettingsDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values
|
|
.SelectMany(v => v.Errors)
|
|
.Select(e => e.ErrorMessage)
|
|
.ToList();
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
{
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
}
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null)
|
|
{
|
|
return Json(new { success = false, message = "Company not found." });
|
|
}
|
|
|
|
// Update company properties
|
|
_mapper.Map(dto, company);
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Company {CompanyId} settings updated by user", companyId);
|
|
|
|
return Json(new { success = true, message = "Company information updated successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating company information");
|
|
return Json(new { success = false, message = "An error occurred while updating company information." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
|
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
|
/// (legacy). On first access of a legacy DB-stored logo the file is automatically migrated to the
|
|
/// filesystem and the DB columns are cleared, so the migration happens transparently without a
|
|
/// separate migration script. The 1-hour response cache prevents redundant DB hits on pages that
|
|
/// display the logo repeatedly (e.g. every request to the sidebar layout).
|
|
/// </summary>
|
|
// GET: CompanySettings/Logo
|
|
[HttpGet]
|
|
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")]
|
|
public async Task<IActionResult> Logo()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return NotFound();
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null) return NotFound();
|
|
|
|
// Try filesystem first
|
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
|
{
|
|
var (success, fileContent, contentType, errorMessage) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
|
if (success)
|
|
{
|
|
return File(fileContent, contentType);
|
|
}
|
|
}
|
|
|
|
// Fallback to database (legacy)
|
|
if (company.LogoData != null && company.LogoData.Length > 0)
|
|
{
|
|
// Migrate to filesystem on access
|
|
var tempFile = new FormFile(
|
|
new MemoryStream(company.LogoData),
|
|
0,
|
|
company.LogoData.Length,
|
|
"logo",
|
|
$"logo{Path.GetExtension(company.LogoContentType ?? ".png")}"
|
|
);
|
|
|
|
var (migrationSuccess, filePath, _) = await _logoService.SaveCompanyLogoAsync(tempFile, companyId.Value);
|
|
if (migrationSuccess)
|
|
{
|
|
company.LogoFilePath = filePath;
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
_logger.LogInformation("Migrated company {CompanyId} logo from database to filesystem", companyId);
|
|
}
|
|
|
|
return File(company.LogoData, company.LogoContentType ?? "image/png");
|
|
}
|
|
|
|
return NotFound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts a multipart logo upload, saves it to the filesystem via <see cref="ICompanyLogoService"/>,
|
|
/// and updates <c>Company.LogoFilePath</c>. Any previously stored filesystem file is deleted first
|
|
/// to prevent orphaned files accumulating over repeated uploads. The legacy DB columns
|
|
/// (<c>LogoData</c> and <c>LogoContentType</c>) are explicitly nulled on every new upload so that
|
|
/// the old binary storage path is permanently retired for this tenant.
|
|
/// Returns a JSON envelope with the new logo URL so the view can hot-swap the img src without reload.
|
|
/// </summary>
|
|
// POST: CompanySettings/UploadLogo
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UploadLogo(IFormFile logoFile)
|
|
{
|
|
try
|
|
{
|
|
if (logoFile == null || logoFile.Length == 0)
|
|
{
|
|
return Json(new { success = false, message = "No file was uploaded." });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
{
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
}
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null)
|
|
{
|
|
return Json(new { success = false, message = "Company not found." });
|
|
}
|
|
|
|
// Delete old logo from filesystem if it exists
|
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
|
{
|
|
await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
|
|
}
|
|
|
|
// Save new logo to filesystem
|
|
var (success, filePath, errorMessage) = await _logoService.SaveCompanyLogoAsync(logoFile, companyId.Value);
|
|
if (!success)
|
|
{
|
|
return Json(new { success = false, message = errorMessage });
|
|
}
|
|
|
|
// Update company record
|
|
company.LogoFilePath = filePath;
|
|
company.LogoData = null; // Clear legacy database storage
|
|
company.LogoContentType = null; // Clear legacy database storage
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Company {CompanyId} logo uploaded to filesystem", companyId);
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = "Logo uploaded successfully.",
|
|
logoUrl = Url.Action("Logo", "CompanySettings")
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error uploading company logo");
|
|
return Json(new { success = false, message = "An error occurred while uploading the logo." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the company logo from both the filesystem and all DB columns (filesystem path and the
|
|
/// legacy binary fields). After this call the sidebar will fall back to the platform PCL logo.
|
|
/// Both storage locations are always cleared in one transaction so the DB and filesystem never
|
|
/// get out of sync regardless of which path currently holds the logo.
|
|
/// </summary>
|
|
// POST: CompanySettings/DeleteLogo
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteLogo()
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
{
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
}
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null)
|
|
{
|
|
return Json(new { success = false, message = "Company not found." });
|
|
}
|
|
|
|
// Delete from filesystem if it exists
|
|
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
|
{
|
|
await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
|
|
}
|
|
|
|
// Clear database fields (both legacy and new)
|
|
company.LogoFilePath = null;
|
|
company.LogoData = null;
|
|
company.LogoContentType = null;
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Company {CompanyId} logo deleted", companyId);
|
|
|
|
return Json(new { success = true, message = "Logo deleted successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting company logo");
|
|
return Json(new { success = false, message = "An error occurred while deleting the logo." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generic helper that persists any <c>CompanyPreferences</c>-mapped DTO via upsert. Because all
|
|
/// preferences live on a single child entity (<see cref="CompanyPreferences"/>) that may not exist
|
|
/// for newly onboarded companies, this method creates the row if absent rather than failing with a
|
|
/// null-reference. All public preference endpoints (<see cref="UpdateAppDefaults"/>,
|
|
/// <see cref="UpdateJobDefaults"/>, <see cref="UpdateNotifications"/>, etc.) delegate here so the
|
|
/// upsert logic is maintained in one place.
|
|
/// </summary>
|
|
private async Task<IActionResult> UpdatePreferences<TDto>(TDto dto, string successMessage)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences);
|
|
if (company == null)
|
|
return Json(new { success = false, message = "Company not found." });
|
|
|
|
if (company.Preferences == null)
|
|
{
|
|
company.Preferences = new CompanyPreferences { CompanyId = companyId.Value };
|
|
_mapper.Map(dto, company.Preferences);
|
|
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
|
|
}
|
|
else
|
|
{
|
|
_mapper.Map(dto, company.Preferences);
|
|
company.Preferences.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompanyPreferences.UpdateAsync(company.Preferences);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return Json(new { success = true, message = successMessage });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating company preferences");
|
|
return Json(new { success = false, message = "An error occurred while saving settings." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves application-level defaults (currency, date format, default payment terms, quote validity days)
|
|
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateAppDefaults
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateAppDefaults([FromBody] UpdateAppDefaultsDto dto) =>
|
|
UpdatePreferences(dto, "Application defaults saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Saves job and workflow defaults (default turnaround days, auto-status transitions, shop access code
|
|
/// requirements) to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateJobDefaults
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateJobDefaults([FromBody] UpdateJobDefaultsDto dto) =>
|
|
UpdatePreferences(dto, "Job/workflow defaults saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Saves notification channel preferences (email enabled, SMS enabled, in-app enabled) to
|
|
/// <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateNotifications
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateNotifications([FromBody] UpdateNotificationsDto dto) =>
|
|
UpdatePreferences(dto, "Notification settings saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Saves data-retention policy preferences (how long to keep soft-deleted records, completed jobs, etc.)
|
|
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateDataRetention
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateDataRetention([FromBody] UpdateDataRetentionDto dto) =>
|
|
UpdatePreferences(dto, "Data retention settings saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Saves Quote PDF layout preferences (footer text, T&C blurb, show/hide columns, signature line)
|
|
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateQuoteTemplate
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateQuoteTemplate([FromBody] UpdateQuoteTemplateDto dto) =>
|
|
UpdatePreferences(dto, "Quote PDF settings saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Saves Invoice PDF layout preferences (payment instructions, bank details, footer text)
|
|
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateInvoiceTemplate
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateInvoiceTemplate([FromBody] UpdateInvoiceTemplateDto dto) =>
|
|
UpdatePreferences(dto, "Invoice PDF settings saved successfully.");
|
|
|
|
// POST: CompanySettings/UpdateWorkOrderTemplate
|
|
[HttpPost]
|
|
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
|
UpdatePreferences(dto, "Work order settings saved successfully.");
|
|
|
|
/// <summary>
|
|
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
|
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
|
/// into <see cref="CompanyOperatingCosts"/>. These values feed the <c>IPricingCalculationService</c>
|
|
/// for every quote and job costing calculation, so changes here take effect on all new quotes
|
|
/// immediately. Uses an upsert pattern identical to <see cref="UpdatePreferences{TDto}"/>.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateOperatingCosts
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateOperatingCosts([FromBody] UpdateOperatingCostsDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values
|
|
.SelectMany(v => v.Errors)
|
|
.Select(e => e.ErrorMessage)
|
|
.ToList();
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
{
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
}
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(
|
|
companyId.Value,
|
|
false,
|
|
c => c.OperatingCosts);
|
|
|
|
if (company == null)
|
|
{
|
|
return Json(new { success = false, message = "Company not found." });
|
|
}
|
|
|
|
// Create or update operating costs
|
|
if (company.OperatingCosts == null)
|
|
{
|
|
company.OperatingCosts = new CompanyOperatingCosts
|
|
{
|
|
CompanyId = companyId.Value
|
|
};
|
|
_mapper.Map(dto, company.OperatingCosts);
|
|
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
|
|
}
|
|
else
|
|
{
|
|
_mapper.Map(dto, company.OperatingCosts);
|
|
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Company {CompanyId} operating costs updated", companyId);
|
|
|
|
return Json(new { success = true, message = "Operating costs updated successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating operating costs");
|
|
return Json(new { success = false, message = "An error occurred while updating operating costs." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the free-text AI context profile to <c>CompanyOperatingCosts.AiContextProfile</c>.
|
|
/// This text is injected into the system prompt by <c>AiQuoteService.BuildSystemPrompt(context)</c>
|
|
/// so the Anthropic Claude model receives company-specific context (specialties, common item types,
|
|
/// pricing philosophy) before analyzing photos. This is one of three layers in the per-company AI
|
|
/// learning approach (manual context, auto pricing config, few-shot accepted predictions).
|
|
/// Leading/trailing whitespace is trimmed before storage to avoid invisible formatting issues in prompts.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateAiProfile
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateAiProfile([FromBody] UpdateAiProfileDto dto)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
|
|
if (company == null)
|
|
return Json(new { success = false, message = "Company not found." });
|
|
|
|
if (company.OperatingCosts == null)
|
|
{
|
|
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
|
|
company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
|
|
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
|
|
}
|
|
else
|
|
{
|
|
company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
|
|
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
_logger.LogInformation("Company {CompanyId} AI profile updated", companyId);
|
|
return Json(new { success = true, message = "AI profile saved successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating AI profile");
|
|
return Json(new { success = false, message = "An error occurred while saving the AI profile." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement
|
|
/// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the
|
|
/// acceptance is then recorded as a <see cref="CompanySmsAgreement"/> audit row.
|
|
/// Disabling never requires agreement.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateSmsPreferences
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto dto)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null)
|
|
return Json(new { success = false, message = "Company not found." });
|
|
|
|
if (dto.SmsEnabled)
|
|
{
|
|
var hasAgreement = await _unitOfWork.CompanySmsAgreements
|
|
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
|
|
|
if (!hasAgreement)
|
|
{
|
|
// Require explicit acceptance of the current terms version
|
|
if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion)
|
|
return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." });
|
|
|
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
|
var userName = User.Identity?.Name ?? string.Empty;
|
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
var ua = Request.Headers.UserAgent.ToString();
|
|
|
|
var agreement = new CompanySmsAgreement
|
|
{
|
|
CompanyId = companyId.Value,
|
|
AgreedByUserId = userId,
|
|
AgreedByUserName = userName,
|
|
AgreedAt = DateTime.UtcNow,
|
|
IpAddress = ip,
|
|
UserAgent = ua,
|
|
TermsVersion = AppConstants.SmsTermsVersion,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _unitOfWork.CompanySmsAgreements.AddAsync(agreement);
|
|
_logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}",
|
|
companyId, AppConstants.SmsTermsVersion, userId, ip);
|
|
}
|
|
}
|
|
|
|
company.SmsEnabled = dto.SmsEnabled;
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled);
|
|
return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating SMS preferences");
|
|
return Json(new { success = false, message = "An error occurred while saving SMS preferences." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
|
|
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
|
|
/// operating cost rates. Returns a pre-filled paragraph the user can review and edit before saving.
|
|
/// </summary>
|
|
// GET: CompanySettings/GenerateAiProfileDraft
|
|
[HttpGet]
|
|
public async Task<IActionResult> GenerateAiProfileDraft()
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "No company found." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
|
|
if (company == null)
|
|
return Json(new { success = false, message = "Company not found." });
|
|
|
|
var costs = company.OperatingCosts;
|
|
|
|
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
|
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
|
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
|
|
|
var sb = new System.Text.StringBuilder();
|
|
|
|
// Opening line
|
|
var location = new[] { company.City, company.State }.Where(s => !string.IsNullOrWhiteSpace(s));
|
|
var locationStr = string.Join(", ", location);
|
|
sb.Append(company.CompanyName);
|
|
if (!string.IsNullOrWhiteSpace(locationStr))
|
|
sb.Append($" is a powder coating shop based in {locationStr}.");
|
|
else
|
|
sb.Append(" is a powder coating shop.");
|
|
sb.AppendLine();
|
|
sb.AppendLine();
|
|
|
|
// Shop size
|
|
if (costs != null)
|
|
{
|
|
var tierLabel = costs.ShopCapabilityTier switch
|
|
{
|
|
ShopCapabilityTier.Garage => "garage/hobbyist",
|
|
ShopCapabilityTier.Small => "small",
|
|
ShopCapabilityTier.Medium => "medium-sized",
|
|
ShopCapabilityTier.Large => "high-volume",
|
|
_ => "small"
|
|
};
|
|
sb.AppendLine($"We are a {tierLabel} operation" +
|
|
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
|
}
|
|
|
|
// Ovens
|
|
if (ovens.Any())
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine("Our curing ovens:");
|
|
foreach (var oven in ovens)
|
|
{
|
|
var parts = new List<string>();
|
|
if (oven.MaxLoadSqFt.HasValue && oven.MaxLoadSqFt > 0)
|
|
parts.Add($"{oven.MaxLoadSqFt:0} sq ft capacity");
|
|
if (oven.DefaultCycleMinutes.HasValue && oven.DefaultCycleMinutes > 0)
|
|
parts.Add($"{oven.DefaultCycleMinutes} min cure cycle");
|
|
var detail = parts.Any() ? $" ({string.Join(", ", parts)})" : "";
|
|
sb.AppendLine($"• {oven.Label}{detail}");
|
|
}
|
|
}
|
|
|
|
// Equipment capabilities inferred from rates
|
|
if (costs != null)
|
|
{
|
|
var capabilities = new List<string>();
|
|
if (costs.SandblasterCostPerHour > 0)
|
|
capabilities.Add("sandblasting / media blasting");
|
|
if (costs.CoatingBoothCostPerHour > 0)
|
|
capabilities.Add("powder coating booth");
|
|
if (capabilities.Any())
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine($"We have in-house {string.Join(" and ", capabilities)} capability.");
|
|
}
|
|
}
|
|
|
|
// Powder/coating categories
|
|
if (coatingCategories.Any())
|
|
{
|
|
sb.AppendLine();
|
|
var catNames = coatingCategories.Select(c => c.DisplayName).ToList();
|
|
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
|
}
|
|
|
|
// Worker roles
|
|
if (workers.Any())
|
|
{
|
|
var roles = workers
|
|
.Select(w => w.Role)
|
|
.Distinct()
|
|
.Select(r => r switch
|
|
{
|
|
ShopWorkerRole.Sandblaster => "sandblasting",
|
|
ShopWorkerRole.Coater => "powder coating",
|
|
ShopWorkerRole.Masker => "masking",
|
|
ShopWorkerRole.QualityControl => "quality control",
|
|
ShopWorkerRole.OvenOperator => "oven operation",
|
|
ShopWorkerRole.Supervisor => "supervision",
|
|
ShopWorkerRole.Maintenance => "equipment maintenance",
|
|
_ => "general labor"
|
|
})
|
|
.Distinct()
|
|
.ToList();
|
|
if (roles.Count > 1)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
|
}
|
|
}
|
|
|
|
// Rates hint
|
|
if (costs != null && costs.StandardLaborRate > 0)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine($"Our standard labor rate is ${costs.StandardLaborRate:0.00}/hr. " +
|
|
$"We target approximately {costs.GeneralMarkupPercentage:0}% markup on all jobs.");
|
|
}
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine("(Edit this profile to add detail about the types of parts you typically coat, " +
|
|
"any brands of powder you prefer, your cure temperature, or anything else that " +
|
|
"helps the AI understand your shop better.)");
|
|
|
|
return Json(new { success = true, draft = sb.ToString().Trim() });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error generating AI profile draft");
|
|
return Json(new { success = false, message = "An error occurred while generating the draft." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the Quoting Calibration / Shop Capability profile. Maps equipment fields onto
|
|
/// <see cref="CompanyOperatingCosts"/> and returns the freshly derived blast and coating
|
|
/// rates so the UI can update the "estimated rate" display without a page reload.
|
|
/// </summary>
|
|
// POST: CompanySettings/UpdateBlastProfile
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateBlastProfile([FromBody] UpdateBlastProfileDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "User does not have a company ID." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
|
|
if (company == null)
|
|
return Json(new { success = false, message = "Company not found." });
|
|
|
|
if (company.OperatingCosts == null)
|
|
{
|
|
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
|
|
_mapper.Map(dto, company.OperatingCosts);
|
|
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
|
|
}
|
|
else
|
|
{
|
|
_mapper.Map(dto, company.OperatingCosts);
|
|
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
_logger.LogInformation("Company {CompanyId} blast profile updated — tier={Tier}, CFM={Cfm}, nozzle=#{Nozzle}",
|
|
companyId, dto.ShopCapabilityTier, dto.CompressorCfm, dto.BlastNozzleSize);
|
|
|
|
var blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(company.OperatingCosts);
|
|
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(company.OperatingCosts);
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
message = "Quoting calibration saved successfully.",
|
|
derivedBlastRate = blastRate,
|
|
derivedCoatingRate = coatingRate
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating blast profile");
|
|
return Json(new { success = false, message = "An error occurred while saving the quoting calibration." });
|
|
}
|
|
}
|
|
|
|
#region Job Status Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all job statuses for the current company ordered by <c>DisplayOrder</c>, with a live
|
|
/// job-count badge per status. The counts are fetched individually per status rather than via a
|
|
/// GROUP BY because the global multi-tenant query filter on <c>Jobs</c> is automatically applied,
|
|
/// guaranteeing the counts reflect only this tenant's data without extra filtering code.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetJobStatuses()
|
|
{
|
|
try
|
|
{
|
|
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
|
|
|
// Add job counts
|
|
foreach (var dto in dtos)
|
|
{
|
|
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
|
|
}
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading job statuses");
|
|
return Json(new { success = false, message = "Error loading job statuses." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new custom job status for the current company and immediately invalidates the
|
|
/// <see cref="ILookupCacheService"/> so subsequent status dropdowns reflect the addition without
|
|
/// a restart. StatusCode uniqueness is enforced at the application level (not just the DB) to
|
|
/// return a user-friendly message rather than a constraint violation exception.
|
|
/// System-defined statuses cannot be overridden by this endpoint — tenant customization is additive only.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateJobStatus([FromBody] CreateJobStatusLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
// Check if status code already exists for this company
|
|
var exists = await _unitOfWork.JobStatusLookups
|
|
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
|
if (exists)
|
|
return Json(new { success = false, message = "Status code already exists" });
|
|
|
|
var status = _mapper.Map<JobStatusLookup>(dto);
|
|
status.CompanyId = companyId.Value;
|
|
await _unitOfWork.JobStatusLookups.AddAsync(status);
|
|
await _unitOfWork.CompleteAsync();
|
|
_lookupCache.InvalidateCompanyCache(companyId.Value);
|
|
|
|
_logger.LogInformation("Job status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);
|
|
|
|
return Json(new { success = true, message = "Status created successfully", id = status.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating job status");
|
|
return Json(new { success = false, message = "An error occurred while creating the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates label, color, and display properties of a custom job status. System-defined statuses
|
|
/// (those where <c>IsSystemDefined == true</c>) are blocked from modification because the core
|
|
/// job lifecycle logic (e.g. terminal-status detection for billing) depends on their fixed identity.
|
|
/// Cache is invalidated after save so live dropdowns pick up the rename immediately.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateJobStatus([FromBody] UpdateJobStatusLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(dto.Id);
|
|
if (status == null)
|
|
return Json(new { success = false, message = "Status not found" });
|
|
|
|
if (status.IsSystemDefined)
|
|
return Json(new { success = false, message = "System-defined statuses cannot be modified" });
|
|
|
|
_mapper.Map(dto, status);
|
|
await _unitOfWork.JobStatusLookups.UpdateAsync(status);
|
|
await _unitOfWork.CompleteAsync();
|
|
_lookupCache.InvalidateCompanyCache(status.CompanyId);
|
|
|
|
_logger.LogInformation("Job status {StatusId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Status updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job status");
|
|
return Json(new { success = false, message = "An error occurred while updating the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a custom job status after verifying it is not currently assigned to any job.
|
|
/// A referential integrity check is performed before the soft-delete rather than relying on a DB
|
|
/// constraint because soft-deleted statuses remain in the table and a constraint would not fire.
|
|
/// System-defined statuses are always protected regardless of job-count.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteJobStatus(int id)
|
|
{
|
|
try
|
|
{
|
|
var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(id);
|
|
if (status == null)
|
|
return Json(new { success = false, message = "Status not found" });
|
|
|
|
if (status.IsSystemDefined)
|
|
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
|
|
|
// Check if status is in use
|
|
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
|
|
if (inUse)
|
|
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
|
|
|
await _unitOfWork.JobStatusLookups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
_lookupCache.InvalidateCompanyCache(status.CompanyId);
|
|
|
|
_logger.LogInformation("Job status {StatusId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Status deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting job status");
|
|
return Json(new { success = false, message = "An error occurred while deleting the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for all job statuses according to the drag-sorted ID list sent
|
|
/// from the UI. Each status is updated individually (no bulk UPDATE) because the generic repository
|
|
/// does not expose a batch-update overload. Cache is invalidated after save so the new order
|
|
/// appears in kanban boards and dropdowns on the next request.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderJobStatuses([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
|
|
if (status != null)
|
|
{
|
|
status.DisplayOrder = i + 1;
|
|
await _unitOfWork.JobStatusLookups.UpdateAsync(status);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
|
|
|
_logger.LogInformation("Job statuses reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering job statuses");
|
|
return Json(new { success = false, message = "An error occurred while reordering statuses." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Job Priority Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all job priorities for the current company ordered by <c>DisplayOrder</c>, including
|
|
/// a live job-count per priority. Counts rely on the tenant-scoped global query filter so no
|
|
/// explicit CompanyId filter is needed. Used by the Settings UI to show which priorities are
|
|
/// actively in use before allowing rename or delete.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetJobPriorities()
|
|
{
|
|
try
|
|
{
|
|
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
|
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
|
|
|
// Add job counts
|
|
foreach (var dto in dtos)
|
|
{
|
|
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
|
|
}
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading job priorities");
|
|
return Json(new { success = false, message = "Error loading job priorities." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new custom job priority with a unique PriorityCode for the current company.
|
|
/// Priorities appear in the job create/edit dropdowns and drive color-coded badges across the UI.
|
|
/// Unlike job statuses, priorities do not require cache invalidation via <see cref="ILookupCacheService"/>
|
|
/// because the priority list is not currently cached — this should be added if performance requires it.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateJobPriority([FromBody] CreateJobPriorityLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
// Check if priority code already exists for this company
|
|
var exists = await _unitOfWork.JobPriorityLookups
|
|
.AnyAsync(p => p.PriorityCode == dto.PriorityCode);
|
|
if (exists)
|
|
return Json(new { success = false, message = "Priority code already exists" });
|
|
|
|
var priority = _mapper.Map<JobPriorityLookup>(dto);
|
|
priority.CompanyId = companyId.Value;
|
|
await _unitOfWork.JobPriorityLookups.AddAsync(priority);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Job priority {PriorityCode} created for company {CompanyId}", dto.PriorityCode, companyId);
|
|
|
|
return Json(new { success = true, message = "Priority created successfully", id = priority.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating job priority");
|
|
return Json(new { success = false, message = "An error occurred while creating the priority." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the label, color, and icon of a custom job priority. System-defined priorities are
|
|
/// protected from modification to keep core features (e.g. Rush job surcharges, SLA alerting)
|
|
/// working against stable codes. AutoMapper applies only the mapped fields so internal flags
|
|
/// (<c>IsSystemDefined</c>, <c>PriorityCode</c>) remain unchanged.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateJobPriority([FromBody] UpdateJobPriorityLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(dto.Id);
|
|
if (priority == null)
|
|
return Json(new { success = false, message = "Priority not found" });
|
|
|
|
if (priority.IsSystemDefined)
|
|
return Json(new { success = false, message = "System-defined priorities cannot be modified" });
|
|
|
|
_mapper.Map(dto, priority);
|
|
await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Job priority {PriorityId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Priority updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job priority");
|
|
return Json(new { success = false, message = "An error occurred while updating the priority." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a custom job priority after confirming it is not assigned to any existing job.
|
|
/// System-defined priorities cannot be deleted. Soft delete is used (rather than hard delete) so
|
|
/// historical job records that referenced the priority retain a coherent display value.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteJobPriority(int id)
|
|
{
|
|
try
|
|
{
|
|
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(id);
|
|
if (priority == null)
|
|
return Json(new { success = false, message = "Priority not found" });
|
|
|
|
if (priority.IsSystemDefined)
|
|
return Json(new { success = false, message = "Cannot delete system-defined priority" });
|
|
|
|
// Check if priority is in use
|
|
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
|
|
if (inUse)
|
|
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
|
|
|
|
await _unitOfWork.JobPriorityLookups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Job priority {PriorityId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Priority deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting job priority");
|
|
return Json(new { success = false, message = "An error occurred while deleting the priority." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for job priorities to match the drag-sorted ID list from the UI.
|
|
/// The 1-based ordering (i+1) ensures DisplayOrder 0 is never stored, which could break ascending
|
|
/// sorts that treat 0 as "unset".
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderJobPriorities([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var priority = priorities.FirstOrDefault(p => p.Id == dto.OrderedIds[i]);
|
|
if (priority != null)
|
|
{
|
|
priority.DisplayOrder = i + 1;
|
|
await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Job priorities reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering job priorities");
|
|
return Json(new { success = false, message = "An error occurred while reordering priorities." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Quote Status Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all quote statuses for the current company ordered by <c>DisplayOrder</c>, including a
|
|
/// live count of quotes in each status. The counts inform the UI about which statuses are safe to
|
|
/// delete versus which need to be retired via rename or deactivation instead.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetQuoteStatuses()
|
|
{
|
|
try
|
|
{
|
|
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
|
|
|
// Add quote counts
|
|
foreach (var dto in dtos)
|
|
{
|
|
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
|
|
}
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading quote statuses");
|
|
return Json(new { success = false, message = "Error loading quote statuses." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new custom quote status for the company. Enforces two critical singleton business rules:
|
|
/// only one status may be flagged <c>IsApprovedStatus</c> (the flag used by the quote-to-job
|
|
/// conversion path) and only one may be flagged <c>IsConvertedStatus</c> (used to mark quotes that
|
|
/// generated a job). Allowing multiple "approved" statuses would break the conversion workflow by
|
|
/// making the trigger condition ambiguous.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateQuoteStatus([FromBody] CreateQuoteStatusLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
// Check if status code already exists for this company
|
|
var exists = await _unitOfWork.QuoteStatusLookups
|
|
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
|
if (exists)
|
|
return Json(new { success = false, message = "Status code already exists" });
|
|
|
|
// Validate business logic flags
|
|
if (dto.IsApprovedStatus)
|
|
{
|
|
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
|
.AnyAsync(s => s.IsApprovedStatus);
|
|
if (hasApproved)
|
|
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
|
}
|
|
|
|
if (dto.IsConvertedStatus)
|
|
{
|
|
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
|
.AnyAsync(s => s.IsConvertedStatus);
|
|
if (hasConverted)
|
|
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
|
}
|
|
|
|
var status = _mapper.Map<QuoteStatusLookup>(dto);
|
|
status.CompanyId = companyId.Value;
|
|
await _unitOfWork.QuoteStatusLookups.AddAsync(status);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Quote status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);
|
|
|
|
return Json(new { success = true, message = "Status created successfully", id = status.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating quote status");
|
|
return Json(new { success = false, message = "An error occurred while creating the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates a custom quote status. Re-validates the <c>IsApprovedStatus</c> and <c>IsConvertedStatus</c>
|
|
/// singleton constraints on every update, but only fires the uniqueness check when the flag is being
|
|
/// newly set (changing from false → true). This avoids a false positive when saving the existing
|
|
/// approved status without modifying the flag.
|
|
/// System-defined statuses are read-only regardless of the DTO content.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateQuoteStatus([FromBody] UpdateQuoteStatusLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(dto.Id);
|
|
if (status == null)
|
|
return Json(new { success = false, message = "Status not found" });
|
|
|
|
if (status.IsSystemDefined)
|
|
return Json(new { success = false, message = "System-defined statuses cannot be modified" });
|
|
|
|
// Validate business logic flags
|
|
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
|
|
{
|
|
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
|
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
|
|
if (hasApproved)
|
|
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
|
}
|
|
|
|
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
|
|
{
|
|
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
|
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
|
|
if (hasConverted)
|
|
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
|
}
|
|
|
|
_mapper.Map(dto, status);
|
|
await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Quote status {StatusId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Status updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating quote status");
|
|
return Json(new { success = false, message = "An error occurred while updating the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a custom quote status after confirming it is not referenced by any existing quote.
|
|
/// System-defined statuses are always protected. Soft delete preserves the status row for historical
|
|
/// quotes that already have this status assigned, avoiding orphaned foreign key references.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteQuoteStatus(int id)
|
|
{
|
|
try
|
|
{
|
|
var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(id);
|
|
if (status == null)
|
|
return Json(new { success = false, message = "Status not found" });
|
|
|
|
if (status.IsSystemDefined)
|
|
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
|
|
|
// Check if status is in use
|
|
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
|
|
if (inUse)
|
|
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
|
|
|
await _unitOfWork.QuoteStatusLookups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Quote status {StatusId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Status deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting quote status");
|
|
return Json(new { success = false, message = "An error occurred while deleting the status." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for quote statuses to match the drag-sorted ID list from the UI.
|
|
/// Uses 1-based ordering to ensure DisplayOrder is never 0, consistent with
|
|
/// <see cref="ReorderJobStatuses"/>.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderQuoteStatuses([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
|
|
if (status != null)
|
|
{
|
|
status.DisplayOrder = i + 1;
|
|
await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Quote statuses reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering quote statuses");
|
|
return Json(new { success = false, message = "An error occurred while reordering statuses." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Prep Service Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all prep services (sandblasting, media blast, chemical strip, etc.) for the current
|
|
/// company ordered by <c>DisplayOrder</c>. Prep services are selectable line items on quote and job
|
|
/// items that add cost to the pricing calculation; this list populates the wizard's Step 4 checkboxes.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetPrepServices()
|
|
{
|
|
try
|
|
{
|
|
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
|
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading prep services");
|
|
return Json(new { success = false, message = "Error loading prep services." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new prep service entry for the current company. Unlike job/quote status lookups,
|
|
/// prep services do not have system-defined entries or uniqueness constraints on a code field —
|
|
/// companies can add multiple services with similar names. The new service is immediately available
|
|
/// in the quote and job item wizards for this tenant.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreatePrepService([FromBody] CreatePrepServiceDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
var service = _mapper.Map<PrepService>(dto);
|
|
service.CompanyId = companyId.Value;
|
|
await _unitOfWork.PrepServices.AddAsync(service);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Prep service {ServiceName} created for company {CompanyId}", dto.ServiceName, companyId);
|
|
|
|
return Json(new { success = true, message = "Prep service created successfully", id = service.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating prep service");
|
|
return Json(new { success = false, message = "An error occurred while creating the prep service." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the name, description, and pricing of an existing prep service. Unlike status lookups,
|
|
/// there are no system-defined prep services that must be protected — all are tenant-owned.
|
|
/// Changes take effect immediately for new quotes; existing quotes that already selected this service
|
|
/// retain their stored price snapshot and are unaffected.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdatePrepService([FromBody] UpdatePrepServiceDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var service = await _unitOfWork.PrepServices.GetByIdAsync(dto.Id);
|
|
if (service == null)
|
|
return Json(new { success = false, message = "Prep service not found" });
|
|
|
|
_mapper.Map(dto, service);
|
|
await _unitOfWork.PrepServices.UpdateAsync(service);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Prep service {ServiceId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Prep service updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating prep service");
|
|
return Json(new { success = false, message = "An error occurred while updating the prep service." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a prep service. No in-use check is performed here because prep service selections
|
|
/// are stored as a navigation collection on job/quote items, not a FK column — removing the lookup
|
|
/// row does not break existing records. Soft delete ensures the service name remains resolvable for
|
|
/// historical display in old quotes and jobs.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeletePrepService(int id)
|
|
{
|
|
try
|
|
{
|
|
var service = await _unitOfWork.PrepServices.GetByIdAsync(id);
|
|
if (service == null)
|
|
return Json(new { success = false, message = "Prep service not found" });
|
|
|
|
await _unitOfWork.PrepServices.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Prep service {ServiceId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Prep service deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting prep service");
|
|
return Json(new { success = false, message = "An error occurred while deleting the prep service." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for prep services to match the drag-sorted ID list from the UI.
|
|
/// Display order controls the sequence in which prep service checkboxes appear in the item wizard.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderPrepServices([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var service = services.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
|
|
if (service != null)
|
|
{
|
|
service.DisplayOrder = i + 1;
|
|
await _unitOfWork.PrepServices.UpdateAsync(service);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Prep services reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering prep services");
|
|
return Json(new { success = false, message = "An error occurred while reordering prep services." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Blast Setups
|
|
|
|
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetBlastSetups()
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
|
|
var result = setups.OrderBy(b => b.DisplayOrder).Select(b => new BlastSetupDto
|
|
{
|
|
Id = b.Id,
|
|
Name = b.Name,
|
|
SetupType = b.SetupType,
|
|
CompressorCfm = b.CompressorCfm,
|
|
BlastNozzleSize = b.BlastNozzleSize,
|
|
PrimarySubstrate = b.PrimarySubstrate,
|
|
BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride,
|
|
IsDefault = b.IsDefault,
|
|
IsActive = b.IsActive,
|
|
DisplayOrder = b.DisplayOrder,
|
|
DerivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b)
|
|
}).ToList();
|
|
return Json(new { success = true, setups = result });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading blast setups");
|
|
return Json(new { success = false, message = "Failed to load blast setups." });
|
|
}
|
|
}
|
|
|
|
/// <summary>Creates or updates a named blast setup.</summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> SaveBlastSetup([FromBody] SaveBlastSetupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data." });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
|
|
CompanyBlastSetup setup;
|
|
bool isNew;
|
|
|
|
if (dto.Id.HasValue && dto.Id.Value > 0)
|
|
{
|
|
setup = await _unitOfWork.BlastSetups.GetByIdAsync(dto.Id.Value);
|
|
if (setup == null || setup.CompanyId != (companyId ?? 0))
|
|
return Json(new { success = false, message = "Blast setup not found." });
|
|
isNew = false;
|
|
}
|
|
else
|
|
{
|
|
setup = new CompanyBlastSetup { CompanyId = companyId ?? 0, CreatedAt = DateTime.UtcNow };
|
|
isNew = true;
|
|
}
|
|
|
|
setup.Name = dto.Name;
|
|
setup.SetupType = dto.SetupType;
|
|
setup.CompressorCfm = dto.CompressorCfm;
|
|
setup.BlastNozzleSize = dto.BlastNozzleSize;
|
|
setup.PrimarySubstrate = dto.PrimarySubstrate;
|
|
setup.BlastRateSqFtPerHourOverride = dto.BlastRateSqFtPerHourOverride;
|
|
setup.IsActive = dto.IsActive;
|
|
setup.UpdatedAt = DateTime.UtcNow;
|
|
|
|
// Enforce single default — clear others when this one becomes default
|
|
if (dto.IsDefault)
|
|
{
|
|
var others = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && b.IsDefault);
|
|
foreach (var other in others.Where(o => o.Id != (dto.Id ?? 0)))
|
|
{
|
|
other.IsDefault = false;
|
|
await _unitOfWork.BlastSetups.UpdateAsync(other);
|
|
}
|
|
}
|
|
setup.IsDefault = dto.IsDefault;
|
|
|
|
if (isNew)
|
|
{
|
|
// Assign display order = max + 1
|
|
var existing = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
|
|
setup.DisplayOrder = existing.Any() ? existing.Max(b => b.DisplayOrder) + 1 : 1;
|
|
await _unitOfWork.BlastSetups.AddAsync(setup);
|
|
}
|
|
else
|
|
{
|
|
await _unitOfWork.BlastSetups.UpdateAsync(setup);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
|
|
_logger.LogInformation("{Action} blast setup {Id} '{Name}' for company {CompanyId}",
|
|
isNew ? "Created" : "Updated", setup.Id, setup.Name, companyId);
|
|
|
|
return Json(new { success = true, id = setup.Id, derivedRate, message = isNew ? "Blast setup added." : "Blast setup updated." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error saving blast setup");
|
|
return Json(new { success = false, message = "An error occurred while saving the blast setup." });
|
|
}
|
|
}
|
|
|
|
/// <summary>Soft-deletes a named blast setup.</summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteBlastSetup(int id)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
var setup = await _unitOfWork.BlastSetups.GetByIdAsync(id);
|
|
if (setup == null || setup.CompanyId != (companyId ?? 0))
|
|
return Json(new { success = false, message = "Blast setup not found." });
|
|
|
|
await _unitOfWork.BlastSetups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Blast setup {Id} deleted", id);
|
|
return Json(new { success = true, message = "Blast setup deleted." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting blast setup");
|
|
return Json(new { success = false, message = "An error occurred while deleting the blast setup." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Appointment Type Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all appointment type lookups for the current company ordered by <c>DisplayOrder</c>,
|
|
/// with a live appointment count per type. Appointment types classify customer drop-off, pick-up,
|
|
/// and estimate visits; the counts help admins see which types are actively in use.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetAppointmentTypes()
|
|
{
|
|
try
|
|
{
|
|
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
|
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
|
|
|
// Add appointment counts
|
|
foreach (var dto in dtos)
|
|
{
|
|
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
|
|
}
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading appointment types");
|
|
return Json(new { success = false, message = "Error loading appointment types." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new appointment type for the current company with a unique TypeCode. TypeCode
|
|
/// uniqueness is enforced at application level to provide a friendly error message; the DB does
|
|
/// not have a unique constraint on this column because the global query filter makes the UNIQUE
|
|
/// INDEX per-company rather than global.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateAppointmentType([FromBody] CreateAppointmentTypeLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
// Check if type code already exists for this company
|
|
var exists = await _unitOfWork.AppointmentTypeLookups
|
|
.AnyAsync(t => t.TypeCode == dto.TypeCode);
|
|
if (exists)
|
|
return Json(new { success = false, message = "Type code already exists" });
|
|
|
|
var type = _mapper.Map<AppointmentTypeLookup>(dto);
|
|
type.CompanyId = companyId.Value;
|
|
await _unitOfWork.AppointmentTypeLookups.AddAsync(type);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Appointment type {TypeCode} created for company {CompanyId}", dto.TypeCode, companyId);
|
|
|
|
return Json(new { success = true, message = "Type created successfully", id = type.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating appointment type");
|
|
return Json(new { success = false, message = "An error occurred while creating the type." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates editable fields (color, display name, icon, description) of an appointment type.
|
|
/// Unlike job/quote statuses, system-defined appointment types CAN be renamed and recolored —
|
|
/// only TypeCode and IsSystemDefined are protected (ignored by AutoMapper in the reverse mapping).
|
|
/// This design allows businesses to brand standard types (e.g. rename "Drop Off" to "Check In")
|
|
/// without breaking the underlying workflow logic that uses TypeCode.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateAppointmentType([FromBody] UpdateAppointmentTypeLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(dto.Id);
|
|
if (type == null)
|
|
return Json(new { success = false, message = "Type not found" });
|
|
|
|
// System-defined types can be edited (color, display name, icon, description)
|
|
// but TypeCode and IsSystemDefined are protected by AutoMapper (ignored in mapping)
|
|
_mapper.Map(dto, type);
|
|
await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Appointment type {TypeId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Type updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating appointment type");
|
|
return Json(new { success = false, message = "An error occurred while updating the type." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes an appointment type after confirming it is not referenced by any existing appointment.
|
|
/// System-defined types cannot be deleted even when they have zero appointments, because platform
|
|
/// features (e.g. automated pick-up reminders) may reference them by TypeCode.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteAppointmentType(int id)
|
|
{
|
|
try
|
|
{
|
|
var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(id);
|
|
if (type == null)
|
|
return Json(new { success = false, message = "Type not found" });
|
|
|
|
if (type.IsSystemDefined)
|
|
return Json(new { success = false, message = "Cannot delete system-defined type" });
|
|
|
|
// Check if type is in use
|
|
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
|
|
if (inUse)
|
|
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
|
|
|
|
await _unitOfWork.AppointmentTypeLookups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Appointment type {TypeId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Type deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting appointment type");
|
|
return Json(new { success = false, message = "An error occurred while deleting the type." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for appointment types to match the drag-sorted ID list from the UI.
|
|
/// Display order controls the sequence in appointment scheduling dropdowns.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderAppointmentTypes([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var type = types.FirstOrDefault(t => t.Id == dto.OrderedIds[i]);
|
|
if (type != null)
|
|
{
|
|
type.DisplayOrder = i + 1;
|
|
await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Appointment types reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering appointment types");
|
|
return Json(new { success = false, message = "An error occurred while reordering types." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Inventory Category Lookups
|
|
|
|
/// <summary>
|
|
/// Returns all inventory categories for the current company ordered by <c>DisplayOrder</c>,
|
|
/// with a live count of inventory items in each category. Categories classify items (powders,
|
|
/// primers, masking supplies, etc.) for filtering and reporting; the counts prevent deleting
|
|
/// categories that are still actively organizing stock.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetInventoryCategories()
|
|
{
|
|
try
|
|
{
|
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
|
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
|
|
|
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
|
|
|
// Add item counts
|
|
foreach (var dto in dtos)
|
|
{
|
|
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
|
|
}
|
|
|
|
return Json(dtos);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading inventory categories");
|
|
return Json(new { success = false, message = "Error loading inventory categories." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new inventory category with a unique CategoryCode for the current company.
|
|
/// CategoryCode uniqueness prevents duplicate categories (e.g. two "POWDER" codes) that would
|
|
/// confuse inventory filtering. The check uses the repository's <c>AnyAsync</c> which applies
|
|
/// the global tenant filter, so only this company's codes are checked.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateInventoryCategory([FromBody] CreateInventoryCategoryLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null)
|
|
return Json(new { success = false, message = "Company not found" });
|
|
|
|
// Check if category code already exists for this company
|
|
var exists = await _unitOfWork.InventoryCategoryLookups
|
|
.AnyAsync(c => c.CategoryCode == dto.CategoryCode);
|
|
if (exists)
|
|
return Json(new { success = false, message = "Category code already exists" });
|
|
|
|
var category = _mapper.Map<InventoryCategoryLookup>(dto);
|
|
category.CompanyId = companyId.Value;
|
|
await _unitOfWork.InventoryCategoryLookups.AddAsync(category);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Inventory category {CategoryCode} created for company {CompanyId}", dto.CategoryCode, companyId);
|
|
|
|
return Json(new { success = true, message = "Category created successfully", id = category.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating inventory category");
|
|
return Json(new { success = false, message = "An error occurred while creating the category." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the display name, color, and icon of an inventory category. There are no system-defined
|
|
/// inventory categories, so all categories are fully editable without restriction.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateInventoryCategory([FromBody] UpdateInventoryCategoryLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.Id);
|
|
if (category == null)
|
|
return Json(new { success = false, message = "Category not found" });
|
|
|
|
_mapper.Map(dto, category);
|
|
await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Inventory category {CategoryId} updated", dto.Id);
|
|
|
|
return Json(new { success = true, message = "Category updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating inventory category");
|
|
return Json(new { success = false, message = "An error occurred while updating the category." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes an inventory category after confirming no inventory items are currently assigned to it.
|
|
/// Unlike status lookups there is no system-defined protection — all inventory categories are
|
|
/// tenant-owned and can be deleted when empty.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteInventoryCategory(int id)
|
|
{
|
|
try
|
|
{
|
|
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(id);
|
|
if (category == null)
|
|
return Json(new { success = false, message = "Category not found" });
|
|
|
|
// Check if category is in use
|
|
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
|
|
if (inUse)
|
|
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
|
|
|
|
await _unitOfWork.InventoryCategoryLookups.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Inventory category {CategoryId} deleted", id);
|
|
|
|
return Json(new { success = true, message = "Category deleted successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting inventory category");
|
|
return Json(new { success = false, message = "An error occurred while deleting the category." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulk-updates <c>DisplayOrder</c> for inventory categories to match the drag-sorted ID list.
|
|
/// Display order controls the sequence in inventory filtering tabs and reporting groupings.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> ReorderInventoryCategories([FromBody] ReorderLookupDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return Json(new { success = false, message = "Invalid data" });
|
|
|
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
|
|
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
|
{
|
|
var category = categories.FirstOrDefault(c => c.Id == dto.OrderedIds[i]);
|
|
if (category != null)
|
|
{
|
|
category.DisplayOrder = i + 1;
|
|
await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Inventory categories reordered");
|
|
|
|
return Json(new { success = true, message = "Order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error reordering inventory categories");
|
|
return Json(new { success = false, message = "An error occurred while reordering categories." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
// =========================================================================
|
|
// OVEN COSTS
|
|
// =========================================================================
|
|
#region Oven Costs
|
|
|
|
/// <summary>
|
|
/// Returns all named ovens (OvenCost records) for the current company ordered by display order then
|
|
/// label. An explicit <c>CompanyId</c> filter is used here rather than relying solely on the global
|
|
/// query filter because <c>OvenCost</c> did not originally participate in the multi-tenancy filter
|
|
/// and the explicit check provides a defense-in-depth guarantee. Each oven exposes
|
|
/// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> used by the Oven Scheduler for capacity planning.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetOvenCosts()
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new { success = false, message = "Company not found." });
|
|
|
|
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId.Value))
|
|
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label);
|
|
|
|
var result = ovens.Select(o => new
|
|
{
|
|
id = o.Id,
|
|
label = o.Label,
|
|
costPerHour = o.CostPerHour,
|
|
isActive = o.IsActive,
|
|
displayOrder = o.DisplayOrder,
|
|
maxLoadSqFt = o.MaxLoadSqFt,
|
|
defaultCycleMinutes = o.DefaultCycleMinutes
|
|
});
|
|
|
|
return Json(new { success = true, data = result });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving oven costs");
|
|
return Json(new { success = false, message = "An error occurred while loading oven costs." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new named oven (<see cref="PowderCoating.Core.Entities.OvenCost"/>) for the current
|
|
/// company. The oven is immediately selectable in the Oven Scheduler for batch assignment. Fully
|
|
/// qualified DTO type is used to avoid an import ambiguity with other <c>CreateOvenCostDto</c>
|
|
/// classes that may exist in different namespaces.
|
|
/// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> are optional — the scheduler degrades
|
|
/// gracefully when they are null (no capacity enforcement).
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> CreateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.CreateOvenCostDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new { success = false, message = "Company not found." });
|
|
|
|
var oven = new PowderCoating.Core.Entities.OvenCost
|
|
{
|
|
Label = dto.Label.Trim(),
|
|
CostPerHour = dto.CostPerHour,
|
|
IsActive = dto.IsActive,
|
|
DisplayOrder = dto.DisplayOrder,
|
|
MaxLoadSqFt = dto.MaxLoadSqFt,
|
|
DefaultCycleMinutes = dto.DefaultCycleMinutes,
|
|
CompanyId = companyId.Value,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _unitOfWork.OvenCosts.AddAsync(oven);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, message = "Oven added successfully.", id = oven.Id });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating oven cost");
|
|
return Json(new { success = false, message = "An error occurred while adding the oven." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing oven's label, hourly cost, capacity, and cycle time. An explicit
|
|
/// <c>CompanyId</c> ownership check (<c>oven.CompanyId != companyId.Value</c>) is performed in
|
|
/// addition to the global query filter to prevent tenant A from editing tenant B's oven if the
|
|
/// filter were ever bypassed (defense-in-depth). Deactivating an oven (<c>IsActive = false</c>)
|
|
/// hides it from the Oven Scheduler dropdown without deleting historical batch records.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> UpdateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.UpdateOvenCostDto dto)
|
|
{
|
|
try
|
|
{
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
|
|
return Json(new { success = false, message = string.Join(", ", errors) });
|
|
}
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new { success = false, message = "Company not found." });
|
|
|
|
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.Id);
|
|
if (oven == null || oven.CompanyId != companyId.Value)
|
|
return Json(new { success = false, message = "Oven not found." });
|
|
|
|
oven.Label = dto.Label.Trim();
|
|
oven.CostPerHour = dto.CostPerHour;
|
|
oven.IsActive = dto.IsActive;
|
|
oven.DisplayOrder = dto.DisplayOrder;
|
|
oven.MaxLoadSqFt = dto.MaxLoadSqFt;
|
|
oven.DefaultCycleMinutes = dto.DefaultCycleMinutes;
|
|
oven.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.OvenCosts.UpdateAsync(oven);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, message = "Oven updated successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating oven cost {OvenCostId}", dto.Id);
|
|
return Json(new { success = false, message = "An error occurred while updating the oven." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a named oven after verifying it is not referenced by any existing quotes.
|
|
/// Quotes store <c>OvenCostId</c> as a FK for cost-snapshot purposes; if any quote references
|
|
/// the oven the user is directed to deactivate it instead to preserve those references.
|
|
/// The ownership check mirrors <see cref="UpdateOvenCost"/> for defense-in-depth.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DeleteOvenCost([FromBody] int id)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new { success = false, message = "Company not found." });
|
|
|
|
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(id);
|
|
if (oven == null || oven.CompanyId != companyId.Value)
|
|
return Json(new { success = false, message = "Oven not found." });
|
|
|
|
// Check if any quotes reference this oven
|
|
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
|
|
if (usageCount > 0)
|
|
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
|
|
|
|
await _unitOfWork.OvenCosts.SoftDeleteAsync(oven);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, message = "Oven deleted successfully." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error deleting oven cost {OvenCostId}", id);
|
|
return Json(new { success = false, message = "An error occurred while deleting the oven." });
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
// =========================================================================
|
|
// NOTIFICATION TEMPLATES
|
|
// =========================================================================
|
|
#region Notification Templates
|
|
|
|
/// <summary>
|
|
/// Renders the standalone Notification Templates management page (separate from the main Settings
|
|
/// hub). Calls <see cref="EnsureNotificationTemplatesSeededAsync"/> so that any newly added
|
|
/// canonical notification types are automatically provisioned with default templates on first visit,
|
|
/// without requiring a migration or manual seeding step.
|
|
/// </summary>
|
|
// GET: CompanySettings/NotificationTemplates
|
|
[HttpGet]
|
|
public async Task<IActionResult> NotificationTemplates()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return RedirectToAction(nameof(Index));
|
|
|
|
// Load all existing templates for this company
|
|
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
|
|
|
// Auto-seed any missing canonical combinations
|
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
|
if (seeded > 0)
|
|
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
|
|
|
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
|
.Select(t => new NotificationTemplateDto
|
|
{
|
|
Id = t.Id,
|
|
NotificationType = t.NotificationType,
|
|
Channel = t.Channel,
|
|
DisplayName = t.DisplayName,
|
|
Subject = t.Subject,
|
|
Body = t.Body,
|
|
UpdatedAt = t.UpdatedAt
|
|
}).ToList();
|
|
|
|
return View(dtos);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a single notification template as a JSON object for the inline template editor modal.
|
|
/// Includes the applicable placeholder list (built by <see cref="GetApplicablePlaceholders"/>) so
|
|
/// the editor can display context-sensitive insertion buttons without a separate round-trip.
|
|
/// The <c>isEmail</c> flag drives whether the Subject field is shown in the editor UI.
|
|
/// </summary>
|
|
// GET: CompanySettings/GetTemplateJson/{id}
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetTemplateJson(int id)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
|
|
if (template == null) return NotFound();
|
|
|
|
var placeholders = GetApplicablePlaceholders(template.NotificationType)
|
|
.Select(p => new { placeholder = p.Placeholder, description = p.Description })
|
|
.ToList();
|
|
|
|
return Json(new
|
|
{
|
|
id = template.Id,
|
|
displayName = template.DisplayName,
|
|
notificationType = template.NotificationType.ToString(),
|
|
isEmail = template.Channel == NotificationChannel.Email,
|
|
subject = template.Subject ?? string.Empty,
|
|
body = template.Body ?? string.Empty,
|
|
placeholders
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists inline edits to a notification template from the Settings hub modal editor.
|
|
/// Subject is only stored for Email channel templates — SMS templates do not have a subject line,
|
|
/// so it is explicitly nulled to avoid stale values leaking if a template is ever converted
|
|
/// between channel types. Returns a JSON envelope so the modal can show a success toast without reload.
|
|
/// </summary>
|
|
// POST: CompanySettings/SaveTemplateJson
|
|
[HttpPost]
|
|
public async Task<IActionResult> SaveTemplateJson([FromBody] SaveTemplateJsonRequest req)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(req.Id);
|
|
if (template == null) return Json(new { success = false, message = "Template not found." });
|
|
|
|
template.Subject = template.Channel == NotificationChannel.Email ? req.Subject : null;
|
|
template.Body = req.Body ?? string.Empty;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Notification template {TemplateId} updated via inline editor", template.Id);
|
|
return Json(new { success = true, message = $"Template \"{template.DisplayName}\" saved successfully." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets a notification template to its factory defaults from the inline Settings hub modal editor.
|
|
/// Default content is sourced from <c>SeedData.BuildDefaultNotificationTemplates</c> — the same
|
|
/// source used during initial company seeding — so defaults are always in sync with seed data changes.
|
|
/// Returns the new subject and body in the JSON response so the modal can refresh its editor
|
|
/// fields without a page reload.
|
|
/// </summary>
|
|
// POST: CompanySettings/ResetTemplateJson/{id}
|
|
[HttpPost]
|
|
public async Task<IActionResult> ResetTemplateJson(int id)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
|
|
if (template == null) return Json(new { success = false, message = "Template not found." });
|
|
|
|
var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
|
|
var defaultTemplate = defaults.FirstOrDefault(d =>
|
|
d.NotificationType == template.NotificationType && d.Channel == template.Channel);
|
|
|
|
if (defaultTemplate == null)
|
|
return Json(new { success = false, message = "No default found for this template." });
|
|
|
|
template.Subject = defaultTemplate.Subject;
|
|
template.Body = defaultTemplate.Body;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Notification template {TemplateId} reset to defaults via inline editor", template.Id);
|
|
return Json(new { success = true, message = $"Template \"{template.DisplayName}\" reset to defaults.", newSubject = template.Subject ?? string.Empty, newBody = template.Body });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the dedicated full-page template editor for a single notification template. Populates
|
|
/// <c>ViewBag.Placeholders</c> with the context-sensitive list from <see cref="GetApplicablePlaceholders"/>
|
|
/// so the Razor view can render insertion buttons without a separate AJAX call.
|
|
/// If the template is not found the user is redirected back to the Settings hub's notification tab
|
|
/// rather than shown a 404, to maintain the single-page settings UX.
|
|
/// </summary>
|
|
// GET: CompanySettings/EditTemplate/{id}
|
|
[HttpGet]
|
|
public async Task<IActionResult> EditTemplate(int id)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
|
|
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
|
|
|
|
var dto = new NotificationTemplateDto
|
|
{
|
|
Id = template.Id,
|
|
NotificationType = template.NotificationType,
|
|
Channel = template.Channel,
|
|
DisplayName = template.DisplayName,
|
|
Subject = template.Subject,
|
|
Body = template.Body,
|
|
UpdatedAt = template.UpdatedAt
|
|
};
|
|
|
|
ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
|
|
return View(dto);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists edits from the full-page template editor. On validation failure the view is re-rendered
|
|
/// with the user's in-progress content (not the stored version) so edits are not lost. Redirects
|
|
/// back to the Settings hub's notification tab on success using a fragment URL to restore the tab
|
|
/// state without JavaScript, using TempData for the success toast message.
|
|
/// </summary>
|
|
// POST: CompanySettings/EditTemplate/{id}
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> EditTemplate(int id, UpdateNotificationTemplateDto dto)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
|
|
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
var viewDto = new NotificationTemplateDto
|
|
{
|
|
Id = template.Id,
|
|
NotificationType = template.NotificationType,
|
|
Channel = template.Channel,
|
|
DisplayName = template.DisplayName,
|
|
Subject = dto.Subject,
|
|
Body = dto.Body,
|
|
UpdatedAt = template.UpdatedAt
|
|
};
|
|
ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
|
|
return View(viewDto);
|
|
}
|
|
|
|
template.Subject = template.Channel == NotificationChannel.Email ? dto.Subject : null;
|
|
template.Body = dto.Body ?? string.Empty;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Notification template {TemplateId} updated for company {CompanyId}",
|
|
id, template.CompanyId);
|
|
|
|
TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" saved successfully.";
|
|
return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets a template to factory defaults from the full-page editor. Unlike <see cref="ResetTemplateJson"/>
|
|
/// (which is AJAX-based), this is a standard POST that redirects after completion so the full-page
|
|
/// editor's form state is cleanly replaced. If no default is found for the type+channel combination
|
|
/// (e.g. a future notification type without a seed entry) the redirect still occurs silently rather
|
|
/// than erroring, leaving the existing customized template intact.
|
|
/// </summary>
|
|
// POST: CompanySettings/ResetTemplate/{id}
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ResetTemplate(int id)
|
|
{
|
|
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
|
|
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
|
|
|
|
// Find the canonical default for this type+channel combination
|
|
var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
|
|
var defaultTemplate = defaults.FirstOrDefault(d =>
|
|
d.NotificationType == template.NotificationType && d.Channel == template.Channel);
|
|
|
|
if (defaultTemplate != null)
|
|
{
|
|
template.Subject = defaultTemplate.Subject;
|
|
template.Body = defaultTemplate.Body;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Notification template {TemplateId} reset to defaults", id);
|
|
TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" has been reset to defaults.";
|
|
}
|
|
|
|
return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures every canonical notification type+channel combination has a template row for the given
|
|
/// company. Called on every visit to the Settings Index and NotificationTemplates pages so new
|
|
/// notification types added to <c>SeedData.BuildDefaultNotificationTemplates</c> are automatically
|
|
/// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin.
|
|
/// Also removes stale rows (no longer in the canonical list) that have never been customised
|
|
/// (UpdatedAt == null), so retired notification types disappear from the UI automatically.
|
|
/// Returns the count of changes so the caller can decide whether to reload from the DB.
|
|
/// </summary>
|
|
private async Task<int> EnsureNotificationTemplatesSeededAsync(
|
|
int companyId, List<NotificationTemplate> existing)
|
|
{
|
|
var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId);
|
|
|
|
var toAdd = allDefaults
|
|
.Where(d => !existing.Any(e =>
|
|
e.NotificationType == d.NotificationType && e.Channel == d.Channel))
|
|
.ToList();
|
|
|
|
// Remove rows that are no longer canonical and have never been customised.
|
|
var toRemove = existing
|
|
.Where(e => !allDefaults.Any(d =>
|
|
d.NotificationType == e.NotificationType && d.Channel == e.Channel)
|
|
&& e.UpdatedAt == null)
|
|
.ToList();
|
|
|
|
foreach (var t in toAdd)
|
|
await _unitOfWork.NotificationTemplates.AddAsync(t);
|
|
|
|
foreach (var t in toRemove)
|
|
await _unitOfWork.NotificationTemplates.DeleteAsync(t);
|
|
|
|
if (toAdd.Count > 0 || toRemove.Count > 0)
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return toAdd.Count + toRemove.Count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the list of Handlebars-style placeholder tokens applicable to a given notification type.
|
|
/// The base set (<c>{{companyName}}</c>, <c>{{customerName}}</c>) is common to all types; additional
|
|
/// tokens (quote number, approval URL, job status, payment details, etc.) are added conditionally
|
|
/// based on the notification type so the editor only shows relevant tokens. Tokens are rendered in
|
|
/// email/SMS bodies by the notification delivery service via simple string replacement at send time.
|
|
/// Note: <c>{{quoteExpiry}}</c> and similar optional-phrase tokens expand to an empty string when
|
|
/// the underlying field is null — template authors should never wrap them in conditional logic.
|
|
/// </summary>
|
|
private static List<(string Placeholder, string Description)> GetApplicablePlaceholders(
|
|
NotificationType type)
|
|
{
|
|
var list = new List<(string, string)>
|
|
{
|
|
("{{companyName}}", "Your company name"),
|
|
("{{customerName}}", "The customer's or recipient's name")
|
|
};
|
|
|
|
// Quote types
|
|
if (type is NotificationType.QuoteSent or NotificationType.QuoteApproved
|
|
or NotificationType.QuoteDeclinedByCustomer)
|
|
list.Add(("{{quoteNumber}}", "Quote number (e.g. QT-2501-0001)"));
|
|
|
|
if (type == NotificationType.QuoteSent)
|
|
{
|
|
list.Add(("{{quoteTotal}}", "Quote total amount (formatted as currency)"));
|
|
list.Add(("{{quoteExpiry}}", "Expiry phrase, e.g. \" valid until January 1, 2026\" — blank if no expiry date is set"));
|
|
list.Add(("{{approvalUrl}}", "Unique URL for the customer to view and approve the quote online"));
|
|
}
|
|
|
|
if (type == NotificationType.QuoteDeclinedByCustomer)
|
|
{
|
|
list.Add(("{{response}}", "Customer's response — either \"APPROVED\" or \"DECLINED\""));
|
|
list.Add(("{{declineReasonSection}}", "HTML block containing the decline reason — blank when the customer approved"));
|
|
}
|
|
|
|
// Job types
|
|
if (type is NotificationType.JobStatusChanged or NotificationType.JobReadyForPickup
|
|
or NotificationType.JobCompleted)
|
|
list.Add(("{{jobNumber}}", "Job number (e.g. JOB-2501-0001)"));
|
|
|
|
if (type == NotificationType.JobStatusChanged)
|
|
{
|
|
list.Add(("{{jobStatus}}", "Current job status name"));
|
|
list.Add(("{{jobDueDate}}", "Due date phrase, e.g. \" Expected completion: January 1, 2026.\" — blank if not set"));
|
|
}
|
|
|
|
if (type == NotificationType.JobCompleted)
|
|
list.Add(("{{finalPrice}}", "Final job price (formatted as currency)"));
|
|
|
|
// Invoice types
|
|
if (type is NotificationType.InvoiceSent or NotificationType.PaymentReceived
|
|
or NotificationType.PaymentReminder)
|
|
list.Add(("{{invoiceNumber}}", "Invoice number (e.g. INV-2501-0001)"));
|
|
|
|
if (type == NotificationType.InvoiceSent)
|
|
{
|
|
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
|
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
|
}
|
|
|
|
if (type == NotificationType.PaymentReceived)
|
|
{
|
|
list.Add(("{{paymentAmount}}", "Amount paid (formatted as currency)"));
|
|
list.Add(("{{paymentDate}}", "Date payment was recorded (e.g. January 1, 2026)"));
|
|
list.Add(("{{balanceDue}}", "Remaining balance phrase — blank when paid in full"));
|
|
}
|
|
|
|
if (type == NotificationType.PaymentReminder)
|
|
{
|
|
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
|
list.Add(("{{balanceDue}}", "Outstanding balance (formatted as currency)"));
|
|
list.Add(("{{dueDate}}", "Original due date (e.g. January 1, 2026)"));
|
|
list.Add(("{{daysOverdue}}", "Number of days the invoice is overdue"));
|
|
}
|
|
|
|
return list;
|
|
}
|
|
|
|
#endregion
|
|
|
|
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Returns the per-role hourly labor rates configured for the current company, keyed by
|
|
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
|
|
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
|
|
/// without special-casing an empty state. The global multi-tenant filter on
|
|
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetRoleCosts()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new List<object>());
|
|
|
|
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
|
|
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
|
|
return Json(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
|
|
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
|
|
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
|
|
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
|
|
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
|
|
/// <c>CompanyOperatingCosts</c>.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Json(new { success = false, message = "No company found." });
|
|
|
|
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
|
|
|
|
foreach (var dto in rates)
|
|
{
|
|
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
|
|
if (dto.HourlyRate <= 0)
|
|
{
|
|
// Remove rate if cleared
|
|
if (record != null)
|
|
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
|
|
}
|
|
else if (record == null)
|
|
{
|
|
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
|
|
{
|
|
CompanyId = companyId.Value,
|
|
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
|
|
HourlyRate = dto.HourlyRate,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
else
|
|
{
|
|
record.HourlyRate = dto.HourlyRate;
|
|
record.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return Json(new { success = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error saving role costs");
|
|
return Json(new { success = false, message = "An error occurred saving role rates." });
|
|
}
|
|
}
|
|
|
|
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Initiates the Stripe Connect OAuth flow by redirecting the browser to Stripe's authorization URL.
|
|
/// Guards against unconfigured environments by detecting the placeholder client ID string and showing
|
|
/// a friendly error rather than redirecting to a broken Stripe URL. The <c>state</c> parameter passed
|
|
/// to <see cref="IStripeConnectService.GetOAuthUrl"/> is the companyId (as a string) so the callback
|
|
/// can identify which tenant completed the flow without relying on session state, which is not
|
|
/// reliable across Stripe's redirect.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public IActionResult ConnectStripe()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Forbid();
|
|
|
|
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
|
|
if (string.IsNullOrWhiteSpace(connectClientId) ||
|
|
connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
TempData["Error"] = "Stripe Connect is not configured. A platform administrator must set Stripe:Connect:ConnectClientId in appsettings.json before companies can connect their Stripe accounts.";
|
|
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
|
|
}
|
|
|
|
var redirectUri = Url.Action("StripeCallback", "CompanySettings", null, Request.Scheme)!;
|
|
var url = _stripeConnect.GetOAuthUrl(companyId.Value, redirectUri);
|
|
return Redirect(url);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the OAuth redirect callback from Stripe Connect. Stripe sends the authorization
|
|
/// <paramref name="code"/> and the <paramref name="state"/> value (company ID) we passed in
|
|
/// <see cref="ConnectStripe"/>. If the user declined on Stripe's side, the <paramref name="error"/>
|
|
/// parameter is non-null and the flow is aborted gracefully. On success the connected Stripe
|
|
/// account ID is stored on the company record by <c>HandleOAuthCallbackAsync</c>, enabling
|
|
/// payment link generation for that tenant. Always redirects to the online-payments tab regardless
|
|
/// of outcome so the user sees the updated connection status.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> StripeCallback(string code, string state, string? error = null)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(error))
|
|
{
|
|
TempData["Error"] = $"Stripe connection was declined: {error}";
|
|
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
|
|
}
|
|
|
|
if (!int.TryParse(state, out var companyId))
|
|
{
|
|
TempData["Error"] = "Invalid callback state.";
|
|
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
|
|
}
|
|
|
|
var (success, errorMsg) = await _stripeConnect.HandleOAuthCallbackAsync(code, companyId);
|
|
if (success)
|
|
TempData["Success"] = "Stripe account connected successfully! You can now send payment links to customers.";
|
|
else
|
|
TempData["Error"] = $"Could not connect Stripe: {errorMsg}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unhandled exception in StripeCallback");
|
|
TempData["Error"] = "An unexpected error occurred while connecting your Stripe account. Please try again.";
|
|
}
|
|
|
|
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revokes the Stripe Connect OAuth link for the current company. After disconnection the tenant
|
|
/// can no longer send payment links via Stripe until they reconnect via <see cref="ConnectStripe"/>.
|
|
/// The underlying <c>DisconnectAsync</c> also deauthorizes the token on Stripe's side, not just
|
|
/// clearing the stored account ID — this complies with Stripe's platform requirements for handling
|
|
/// user-initiated disconnections.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> DisconnectStripe()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Forbid();
|
|
|
|
var (success, errorMsg) = await _stripeConnect.DisconnectAsync(companyId.Value);
|
|
if (success)
|
|
return Json(new { success = true, message = "Stripe account disconnected." });
|
|
|
|
return Json(new { success = false, message = errorMsg ?? "Failed to disconnect." });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the online payment surcharge configuration for the current company. Enforces a hard cap
|
|
/// of 3% on percentage-based surcharges to comply with Visa/Mastercard card network rules, which
|
|
/// prohibit merchants from passing more than 3% of the transaction amount as a surcharge to
|
|
/// cardholders. The <c>SurchargeAcknowledged</c> flag is stored so the UI can track whether the
|
|
/// admin has read and accepted the surcharge disclosure notice.
|
|
/// </summary>
|
|
[HttpPost]
|
|
public async Task<IActionResult> SaveOnlinePaymentSettings([FromBody] SaveOnlinePaymentSettingsDto dto)
|
|
{
|
|
try
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return Forbid();
|
|
|
|
// Cap surcharge at 3% to comply with card network rules
|
|
if (dto.SurchargeType == OnlinePaymentSurchargeType.Percent && dto.SurchargeValue > 3m)
|
|
return Json(new { success = false, message = "Surcharge percentage cannot exceed 3% per card network rules." });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null) return NotFound();
|
|
|
|
company.OnlinePaymentSurchargeType = dto.SurchargeType;
|
|
company.OnlinePaymentSurchargeValue = dto.SurchargeValue;
|
|
company.OnlineSurchargeAcknowledged = dto.SurchargeAcknowledged;
|
|
company.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.Companies.UpdateAsync(company);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, message = "Online payment settings saved." });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error saving online payment settings");
|
|
return Json(new { success = false, message = "An error occurred saving settings." });
|
|
}
|
|
}
|
|
|
|
// ─── Account Deletion ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Shows the self-service account deletion confirmation page. Loads a summary of the company's
|
|
/// data counts so the admin can see exactly what will be affected before committing.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> DeleteAccount()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return RedirectToAction(nameof(Index));
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
|
if (company == null) return NotFound();
|
|
|
|
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
|
|
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
|
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
|
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
|
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
|
|
|
ViewBag.CompanyName = company.CompanyName;
|
|
ViewBag.UserCount = userCount;
|
|
ViewBag.JobCount = jobCount;
|
|
ViewBag.QuoteCount = quoteCount;
|
|
ViewBag.CustomerCount = custCount;
|
|
ViewBag.InvoiceCount = invCount;
|
|
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes the self-service account deletion request. Requires the admin to type "DELETE" and
|
|
/// check the acknowledgement checkbox. On success: soft-deletes the company, deactivates every
|
|
/// user account, writes a full audit log entry, signs the requesting user out, then redirects to
|
|
/// the login page with a confirmation message. All data is retained in the database under soft-delete
|
|
/// so SuperAdmins can audit or recover records; no rows are physically removed here.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteAccount(string confirmationWord, bool acknowledged)
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
if (companyId == null) return RedirectToAction(nameof(Index));
|
|
|
|
// ── Validate inputs ──────────────────────────────────────────────────
|
|
if (!acknowledged)
|
|
{
|
|
TempData["Error"] = "You must check the acknowledgement checkbox to proceed.";
|
|
return RedirectToAction(nameof(DeleteAccount));
|
|
}
|
|
|
|
if (!string.Equals(confirmationWord?.Trim(), "DELETE", StringComparison.Ordinal))
|
|
{
|
|
TempData["Error"] = "Confirmation word did not match. Please type DELETE (all capitals) to confirm.";
|
|
return RedirectToAction(nameof(DeleteAccount));
|
|
}
|
|
|
|
var requestingUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
var requestingUserName = User.Identity?.Name ?? "Unknown";
|
|
|
|
try
|
|
{
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Users);
|
|
if (company == null) return NotFound();
|
|
|
|
var companyName = company.CompanyName;
|
|
|
|
// ── Gather counts for the audit snapshot ─────────────────────────
|
|
var userCount = company.Users.Count;
|
|
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
|
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
|
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
|
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
|
|
|
|
// ── Soft-delete the company ───────────────────────────────────────
|
|
var now = DateTime.UtcNow;
|
|
company.IsDeleted = true;
|
|
company.IsActive = false;
|
|
company.UpdatedAt = now;
|
|
|
|
// ── Deactivate all user accounts ─────────────────────────────────
|
|
foreach (var user in company.Users)
|
|
{
|
|
user.IsActive = false;
|
|
user.UpdatedAt = now;
|
|
await _userManager.UpdateAsync(user);
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// ── Write audit log ───────────────────────────────────────────────
|
|
await _auditLog.LogAsync(new AuditLog
|
|
{
|
|
UserId = requestingUserId,
|
|
UserName = requestingUserName,
|
|
CompanyId = companyId.Value,
|
|
CompanyName = companyName,
|
|
Action = "SelfServiceAccountDeletion",
|
|
EntityType = "Company",
|
|
EntityId = companyId.Value.ToString(),
|
|
EntityDescription = companyName,
|
|
NewValues = $"Company soft-deleted via self-service. " +
|
|
$"Users deactivated: {userCount}. " +
|
|
$"Jobs: {jobCount}, Quotes: {quoteCount}, " +
|
|
$"Customers: {custCount}, Invoices: {invCount}. " +
|
|
$"Requested by: {requestingUserName} ({requestingUserId}).",
|
|
Timestamp = now,
|
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
|
|
});
|
|
|
|
_logger.LogWarning(
|
|
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
|
|
"Users:{UserCount} Jobs:{JobCount} Quotes:{QuoteCount} Customers:{CustCount} Invoices:{InvCount}",
|
|
companyName, companyId.Value, requestingUserName,
|
|
userCount, jobCount, quoteCount, custCount, invCount);
|
|
|
|
// ── Sign out and redirect ─────────────────────────────────────────
|
|
await _signInManager.SignOutAsync();
|
|
|
|
TempData["AccountDeleted"] = "true";
|
|
TempData["DeletedCompanyName"] = companyName;
|
|
return RedirectToPage("/Account/Login", new { area = "Identity" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error during self-service account deletion for company {CompanyId}", companyId);
|
|
TempData["Error"] = "An error occurred while deleting your account. Please contact support.";
|
|
return RedirectToAction(nameof(DeleteAccount));
|
|
}
|
|
}
|
|
}
|
|
|
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
|
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
|
public record SaveOnlinePaymentSettingsDto(
|
|
OnlinePaymentSurchargeType SurchargeType,
|
|
decimal SurchargeValue,
|
|
bool SurchargeAcknowledged);
|