2791 lines
123 KiB
C#
2791 lines
123 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.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 ApplicationDbContext _context;
|
|
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,
|
|
ApplicationDbContext context,
|
|
UserManager<ApplicationUser> userManager,
|
|
SignInManager<ApplicationUser> signInManager)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_tenantContext = tenantContext;
|
|
_logger = logger;
|
|
_logoService = logoService;
|
|
_lookupCache = lookupCache;
|
|
_stripeConnect = stripeConnect;
|
|
_configuration = configuration;
|
|
_context = context;
|
|
_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 AllowOnlinePayments from subscription plan config
|
|
var planConfig = await _context.Set<SubscriptionPlanConfig>()
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
|
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
|
|
|
// 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>
|
|
/// 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.
|
|
/// Returns the count of newly added templates 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();
|
|
|
|
foreach (var t in toAdd)
|
|
await _unitOfWork.NotificationTemplates.AddAsync(t);
|
|
|
|
if (toAdd.Count > 0)
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return toAdd.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 _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
|
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
|
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
|
var invCount = await _context.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 _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
|
|
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
|
|
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
|
|
var invCount = await _context.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 ───────────────────────────────────────────────
|
|
_context.AuditLogs.Add(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()
|
|
});
|
|
await _context.SaveChangesAsync();
|
|
|
|
_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);
|