Compare commits

..

6 Commits

Author SHA1 Message Date
spouliot c5c1244177 Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages
- Live pricing summary and Job Costing card updates on save
- PatchItem legacy fallback for jobs without PricingBreakdownJson
- GetCostingBreakdown revenue from FinalPrice (not invoice total)
- Help docs: Inline Price Editing sections added to all three detail pages
- AI knowledge base updated with inline editing and costing revenue behavior
- AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs
- Resolve conflict in Payment/Index.cshtml (em dash entity style)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:35:29 -04:00
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
234 changed files with 765 additions and 143065 deletions
@@ -44,20 +44,6 @@ namespace PowderCoating.Application.DTOs.Company
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary> /// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; } public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty; public string SmsTermsVersion { get; set; } = string.Empty;
// Timeclock settings
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
public int? TimeclockAutoClockOutHours { get; set; }
}
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
public class UpdateTimeclockSettingsDto
{
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
public int? TimeclockAutoClockOutHours { get; set; }
} }
/// <summary> /// <summary>
@@ -1,174 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Application.DTOs.Company;
// ============================================================================
// LIST DTO - For Company Settings tab table
// ============================================================================
public class CustomItemTemplateListDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public int FieldCount { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public bool IsActive { get; set; }
public int DisplayOrder { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// FULL DTO - For Edit modal and formula evaluation
// ============================================================================
public class CustomItemTemplateDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// CREATE DTO
// ============================================================================
public class CreateCustomItemTemplateDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
[Required]
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
}
// ============================================================================
// UPDATE DTO
// ============================================================================
public class UpdateCustomItemTemplateDto
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Required]
public string OutputMode { get; set; } = "FixedRate";
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
// ============================================================================
public class CustomItemTemplatePickerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// AI GENERATION DTOs
// ============================================================================
public class GenerateFormulaFromAiRequest
{
[Required]
public string Description { get; set; } = string.Empty;
}
public class GenerateFormulaFromAiResponse
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Name { get; set; }
public string? OutputMode { get; set; }
public string? FieldsJson { get; set; }
public string? Formula { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Reasoning { get; set; }
/// <summary>Result of running the formula with any sample values found in the description.</summary>
public decimal? VerificationResult { get; set; }
public string? VerificationInputs { get; set; }
}
// ============================================================================
// FORMULA EVALUATION DTOs
// ============================================================================
public class EvaluateFormulaRequest
{
[Required]
public string Formula { get; set; } = string.Empty;
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
[Required]
public string VariablesJson { get; set; } = "{}";
}
public class EvaluateFormulaResponse
{
public bool Success { get; set; }
public decimal? Result { get; set; }
public string? Error { get; set; }
}
@@ -1,78 +0,0 @@
namespace PowderCoating.Application.DTOs.Company;
// ── Browse / card display ──────────────────────────────────────────────────
/// <summary>Lean DTO for the community library browse grid card.</summary>
public class FormulaLibraryCardDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
public string SourceCompanyName { get; set; } = string.Empty;
public int ImportCount { get; set; }
public DateTime SharedAt { get; set; }
public string? DiagramImagePath { get; set; }
/// <summary>Non-null when this formula was derived from another library entry.</summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public string? InspiredByName { get; set; }
public string? InspiredByCompanyName { get; set; }
/// <summary>True when the current company has already imported this entry.</summary>
public bool AlreadyImported { get; set; }
/// <summary>True when this formula was shared by the current browsing company.</summary>
public bool IsOwnFormula { get; set; }
/// <summary>Total thumbs-up votes across all companies.</summary>
public int ThumbsUp { get; set; }
/// <summary>Total thumbs-down votes across all companies.</summary>
public int ThumbsDown { get; set; }
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
public bool? MyVote { get; set; }
}
// ── Full detail (import preview modal) ────────────────────────────────────
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
{
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int FieldCount { get; set; }
}
// ── Share from Company Settings ───────────────────────────────────────────
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
public class ShareFormulaRequest
{
public int CustomItemTemplateId { get; set; }
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
}
// ── Company Settings list view ─────────────────────────────────────────────
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
public class FormulaLibraryStatusDto
{
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
public int? LibraryItemId { get; set; }
public bool IsPublished { get; set; }
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
public bool CanShare { get; set; }
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
public string? ImportedFromName { get; set; }
public string? ImportedFromCompany { get; set; }
}
@@ -20,7 +20,7 @@ public class EquipmentDto
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
public string? Location { get; set; } public string? Location { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
public int? DaysUntilMaintenance { get; set; } public int? DaysUntilMaintenance { get; set; }
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")] [Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
[Display(Name = "Recommended Maintenance Interval (Days)")] [Display(Name = "Recommended Maintenance Interval (Days)")]
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
[Display(Name = "Last Maintenance Date")] [Display(Name = "Last Maintenance Date")]
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
@@ -21,11 +21,6 @@ public class JobImportDto
[Name("CustomerName")] [Name("CustomerName")]
public string? CustomerName { get; set; } public string? CustomerName { get; set; }
// Optional short label for the job (maps directly to Job.Description).
// When blank, the system falls back to SpecialInstructions, then "Imported job".
[Name("Description")]
public string? Description { get; set; }
[Name("Status")] [Name("Status")]
public string Status { get; set; } = "Pending"; public string Status { get; set; } = "Pending";
@@ -68,7 +68,6 @@ public class InventoryListDto
public string? CategoryName { get; set; } public string? CategoryName { get; set; }
public string Category { get; set; } = string.Empty; // Legacy field public string Category { get; set; } = string.Empty; // Legacy field
public string? ColorName { get; set; } public string? ColorName { get; set; }
public string? Location { get; set; }
public decimal QuantityOnHand { get; set; } public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs"; public string UnitOfMeasure { get; set; } = "lbs";
public decimal ReorderPoint { get; set; } public decimal ReorderPoint { get; set; }
@@ -325,11 +325,7 @@ public class JobItemDto
public bool IsGenericItem { get; set; } public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; } public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; } public bool IsSalesItem { get; set; }
public bool IsAiItem { get; set; }
public string? Sku { get; set; } public string? Sku { get; set; }
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
public List<JobItemCoatDto> Coats { get; set; } = new(); public List<JobItemCoatDto> Coats { get; set; } = new();
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new(); public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
} }
@@ -490,7 +486,6 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; } public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; } public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
@@ -516,11 +511,6 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; } public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Rework job creation (opt-in)
public bool CreateReworkJob { get; set; }
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
} }
public class UpdateReworkRecordDto public class UpdateReworkRecordDto
@@ -475,11 +475,6 @@ public class QuoteItemDto
public bool IsAiItem { get; set; } public bool IsAiItem { get; set; }
// Custom formula item
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
// Cost breakdown snapshot // Cost breakdown snapshot
public decimal ItemMaterialCost { get; set; } public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; } public decimal ItemLaborCost { get; set; }
@@ -564,11 +559,6 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items) // ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
} }
// ============================================================================ // ============================================================================
@@ -884,9 +874,4 @@ public class QuotePricingResult
// Per-item results (same order as input items) // Per-item results (same order as input items)
public List<QuoteItemPricingResult> ItemResults { get; set; } = new(); public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
public decimal CustomPowderOrderAmount { get; set; }
public List<string> CustomPowderOrderColors { get; set; } = new();
} }
@@ -26,7 +26,6 @@ public class SubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; } public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; } public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; } public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
@@ -75,7 +74,6 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; } public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; } public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; } public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }
@@ -1,74 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Timeclock;
public class EmployeeClockEntryDto
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public string UserDisplayName { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public decimal? HoursWorked { get; set; }
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public bool IsOpen => ClockOutTime == null;
}
public class ClockInRequest
{
public string? Notes { get; set; }
}
public class ClockOutRequest
{
public int EntryId { get; set; }
public string? Notes { get; set; }
}
/// <summary>
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
/// The server determines whether to clock in or clock out based on the employee's open entry.
/// </summary>
public class KioskPunchRequest
{
public string UserId { get; set; } = string.Empty;
public string Pin { get; set; } = string.Empty;
}
public class EditClockEntryRequest
{
public int Id { get; set; }
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>
/// Sent when an employee clicks Break or Lunch to pause their work segment.
/// The server closes the current Work entry and opens a Break/Lunch entry.
/// </summary>
public class GoOnBreakRequest
{
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
public ClockEntryType BreakType { get; set; }
}
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
public class ManualEntryRequest
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
public class KioskEmployeeDto
{
public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Initials { get; set; } = string.Empty;
/// <summary>True when the employee has an open clock entry right now.</summary>
public bool IsClockedIn { get; set; }
}
@@ -125,8 +125,6 @@ public class CreateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
// ============================================================================ // ============================================================================
@@ -211,6 +209,4 @@ public class UpdateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
@@ -18,8 +18,7 @@ public class WizardProgressDto
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step); public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step); public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display. public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100); public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
} }
@@ -1,30 +0,0 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
public interface ICustomFormulaAiService
{
/// <summary>
/// Generates a NCalc formula, field list, and notes from a natural-language description
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
/// ready to pre-fill the template editor.
/// </summary>
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null);
/// <summary>
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
/// Safe server-side only — no user-controlled code execution.
/// </summary>
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
/// <summary>
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
/// Returns the normalized formula string and a null error on success, or the original
/// formula and an error message on failure.
/// </summary>
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
}
@@ -1,61 +0,0 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
/// </summary>
public interface IFormulaLibraryService
{
/// <summary>
/// Returns all published library entries, with AlreadyImported populated for the given company.
/// Optionally filters by search term, output mode, or industry hint.
/// </summary>
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId,
string? search = null,
string? outputMode = null,
string? industryHint = null);
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
/// <summary>
/// Publishes a company template to the community library.
/// If the template was previously shared and unpublished, re-publishes the existing row.
/// Updates the library entry fields from the current template state on re-share.
/// </summary>
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
Task UnshareAsync(int libraryItemId, int companyId);
/// <summary>
/// Copies a library entry into the company's local CustomItemTemplate table.
/// If the company already has an import record for this entry, returns the existing template id.
/// </summary>
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
/// <summary>
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
/// eligible to be shared, and where it was imported from if applicable.
/// </summary>
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
/// <summary>
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
/// when a source template's diagram is removed. Call from CompanySettingsController
/// when a diagram is deleted or replaced.
/// </summary>
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
/// <summary>
/// Records or toggles a thumbs-up/down vote from the given company.
/// If the same vote already exists it is removed (toggle off).
/// If the opposite vote exists it is replaced.
/// Companies cannot rate their own formulas.
/// Returns the updated counts for the library entry.
/// </summary>
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
int libraryItemId, int companyId, bool isPositive);
}
@@ -13,12 +13,4 @@ public interface IQuotePricingAssemblyService
int companyId, int companyId,
decimal? ovenRateOverride, decimal? ovenRateOverride,
DateTime createdAtUtc); DateTime createdAtUtc);
/// <summary>
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
/// referenced by coats on the given quote, then links those coats to the new inventory records.
/// Must be called after a quote transitions to Approved status.
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
/// </summary>
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
} }
@@ -1,41 +0,0 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class CustomItemTemplateProfile : Profile
{
public CustomItemTemplateProfile()
{
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
}
private static int CountFields(string fieldsJson)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
? doc.RootElement.GetArrayLength()
: 0;
}
catch
{
return 0;
}
}
}
@@ -1,35 +0,0 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class FormulaLibraryProfile : Profile
{
public FormulaLibraryProfile()
{
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.InspiredByName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
.ForMember(dest => dest.InspiredByCompanyName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
}
private static int CountFields(string fieldsJson)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
? doc.RootElement.GetArrayLength()
: 0;
}
catch { return 0; }
}
}
@@ -196,9 +196,7 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription, .ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null)) opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.ForMember(dest => dest.ReworkJobNumber, .ForMember(dest => dest.ReworkJobNumber,
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null)) opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
.ForMember(dest => dest.ReworkPricingType,
opt => opt.MapFrom(src => src.ReworkPricingType));
// Job → JobDto (rework fields) // Job → JobDto (rework fields)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller) // (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -159,7 +159,6 @@ public class QuoteProfile : Profile
.ReverseMap() .ReverseMap()
.ForMember(dest => dest.Quote, opt => opt.Ignore()) .ForMember(dest => dest.Quote, opt => opt.Ignore())
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore()) .ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
.ForMember(dest => dest.Coats, opt => opt.Ignore()) .ForMember(dest => dest.Coats, opt => opt.Ignore())
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) .ForMember(dest => dest.PrepServices, opt => opt.Ignore())
.ForMember(dest => dest.CompanyId, opt => opt.Ignore()) .ForMember(dest => dest.CompanyId, opt => opt.Ignore())
@@ -181,7 +180,6 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore()) .ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
.ForMember(dest => dest.CompanyId, opt => opt.Ignore()) .ForMember(dest => dest.CompanyId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -192,10 +190,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore()); .ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
// QuoteItem -> CreateQuoteItemDto (for Edit view) // QuoteItem -> CreateQuoteItemDto (for Edit view)
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
// is unreliable for ICollection<T> → List<T2> with different element types.
CreateMap<QuoteItem, CreateQuoteItemDto>() CreateMap<QuoteItem, CreateQuoteItemDto>()
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices)); .ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================ // ============================================================================
@@ -16,7 +16,6 @@
<PackageReference Include="FluentValidation" Version="11.11.0" /> <PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" /> <PackageReference Include="QuestPDF" Version="2024.12.3" />
@@ -53,10 +53,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -160,10 +157,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -265,10 +259,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId, AiPredictionId = source.AiPredictionId
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -362,9 +353,6 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity, Complexity = seed.Complexity,
AiTags = seed.AiTags, AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId, AiPredictionId = seed.AiPredictionId,
IsCustomFormulaItem = seed.IsCustomFormulaItem,
CustomItemTemplateId = seed.CustomItemTemplateId,
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -492,9 +480,6 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; } public string? Complexity { get; init; }
public string? AiTags { get; init; } public string? AiTags { get; init; }
public int? AiPredictionId { get; init; } public int? AiPredictionId { get; init; }
public bool IsCustomFormulaItem { get; init; }
public int? CustomItemTemplateId { get; init; }
public string? FormulaFieldValuesJson { get; init; }
} }
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary> /// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -220,16 +220,6 @@ public class PricingCalculationService : IPricingCalculationService
}; };
} }
/// <summary>
/// Returns true when a coat requires ordering custom powder that is not in inventory.
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
/// </summary>
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
/// <summary> /// <summary>
/// Calculates the total price for a single quote line item, routing to the correct pricing /// Calculates the total price for a single quote line item, routing to the correct pricing
/// path based on item type: /// path based on item type:
@@ -298,27 +288,6 @@ public class PricingCalculationService : IPricingCalculationService
}; };
} }
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{
var formulaTotal = item.ManualUnitPrice.Value;
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = formulaTotal,
UnitPrice = formulaUnitPrice,
TotalPrice = formulaTotal
};
}
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation. // Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue) if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{ {
@@ -343,8 +312,6 @@ public class PricingCalculationService : IPricingCalculationService
{ {
for (int i = 0; i < item.Coats.Count; i++) for (int i = 0; i < item.Coats.Count; i++)
{ {
// Custom powder material moves to the "Custom Powder Order" line item
if (IsCustomPowderCoat(item.Coats[i])) continue;
var coatResult = await CalculateCoatPriceAsync( var coatResult = await CalculateCoatPriceAsync(
item.Coats[i], 0m, item.Quantity, i, 0, companyId); item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost; coatMaterialCost += coatResult.CoatMaterialCost;
@@ -446,9 +413,7 @@ public class PricingCalculationService : IPricingCalculationService
for (int ci = 0; ci < item.Coats.Count; ci++) for (int ci = 0; ci < item.Coats.Count; ci++)
{ {
var coat = item.Coats[ci]; var coat = item.Coats[ci];
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
&& !IsCustomPowderCoat(coat))
{ {
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId); var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
totalMaterialCost += coatResult.CoatMaterialCost; totalMaterialCost += coatResult.CoatMaterialCost;
@@ -466,8 +431,7 @@ public class PricingCalculationService : IPricingCalculationService
{ {
var firstCoatResult = await CalculateCoatPriceAsync( var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId); item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor totalMaterialCost = firstCoatResult.CoatMaterialCost;
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
coatLaborCost = firstCoatResult.CoatLaborCost; coatLaborCost = firstCoatResult.CoatLaborCost;
totalLaborCost = coatLaborCost; totalLaborCost = coatLaborCost;
} }
@@ -664,49 +628,6 @@ public class PricingCalculationService : IPricingCalculationService
// 4. TOTAL ITEMS SUBTOTAL // 4. TOTAL ITEMS SUBTOTAL
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal; var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
// Powder-to-order costs are excluded from individual item prices and collected in a
// "Custom Powder Order" line item added at save time. For live pricing previews (before
// save), add them back here so the displayed total stays correct throughout the session.
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
bool hasCustomPowderOrderItem = items.Any(i =>
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
decimal customPowderOrderAmount = 0m;
var customPowderOrderColors = new List<string>();
if (!hasCustomPowderOrderItem)
{
foreach (var item in items.Where(i => i.Coats != null))
{
foreach (var c in item.Coats!)
{
if (!c.InventoryItemId.HasValue &&
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
{
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(c.ColorName))
customPowderOrderColors.Add(c.ColorName);
}
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
{
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName);
}
}
}
}
if (customPowderOrderAmount > 0)
{
itemsSubtotal += customPowderOrderAmount;
totalMaterialCosts += customPowderOrderAmount;
}
}
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate) // 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
// AI items already have oven cost baked into their AI-estimated price, so we only // AI items already have oven cost baked into their AI-estimated price, so we only
// charge the proportion of the oven that's attributable to non-AI items. // charge the proportion of the oven that's attributable to non-AI items.
@@ -885,11 +806,7 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2), MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2), LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2), EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
ItemResults = itemResults, ItemResults = itemResults
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
CustomPowderOrderColors = customPowderOrderColors
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
}; };
} }
} }
@@ -90,9 +90,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{ {
ArgumentNullException.ThrowIfNull(itemDtos); ArgumentNullException.ThrowIfNull(itemDtos);
var dtoList = itemDtos.ToList();
var items = new List<QuoteItem>(); var items = new List<QuoteItem>();
foreach (var itemDto in dtoList) foreach (var itemDto in itemDtos)
{ {
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc); var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride); await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
@@ -103,17 +102,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
items.Add(item); items.Add(item);
} }
// Option B: auto-create the Custom Powder Order item only on first save.
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
if (!hasExistingCustomPowderOrder)
{
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
if (customPowderItem != null)
items.Add(customPowderItem);
}
return items; return items;
} }
@@ -142,14 +130,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return; return;
} }
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue) if (itemDto.CatalogItemId.HasValue)
{ {
if (itemDto.Coats != null && itemDto.Coats.Any()) if (itemDto.Coats != null && itemDto.Coats.Any())
@@ -181,10 +161,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
/// <summary> /// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing. /// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on /// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system /// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the /// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// quote (deduplication). No inventory is created during quote save.
/// </summary> /// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{ {
@@ -196,8 +175,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{ {
var coatDto = itemDto.Coats[coatIndex]; var coatDto = itemDto.Coats[coatIndex];
// Incoming-inventory creation is intentionally deferred to quote approval. if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
// PowderCatalogItemId is persisted on the coat entity for later use. coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc); var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync( var coatPricing = await _pricingService.CalculateCoatPriceAsync(
@@ -264,9 +243,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem, IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags, AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId, AiPredictionId = itemDto.AiPredictionId,
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
CustomItemTemplateId = itemDto.CustomItemTemplateId,
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -280,7 +256,6 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
CoatName = coatDto.CoatName, CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence, Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId, InventoryItemId = coatDto.InventoryItemId,
PowderCatalogItemId = coatDto.CatalogItemId,
ColorName = coatDto.ColorName, ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId, VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode, ColorCode = coatDto.ColorCode,
@@ -330,36 +305,34 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
} }
/// <summary> /// <summary>
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry. /// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// Called at quote-approval time (not during quote save) so inventory records only appear /// platform catalog that doesn't yet exist in their company's inventory.
/// when a job is actually going to be created. The caller groups coats by
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
/// duplicate records when the same powder appears on multiple items in the same quote.
/// ///
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER") /// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
/// so the item always lands in the right bucket regardless of how many IsCoating categories /// forcing the user to manually add the powder to inventory before quoting, we create an
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category. /// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
/// ///
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families) /// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// from the manufacturer product page. Best-effort — item is still created from catalog data /// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if the AI call fails. /// if it fails, the item is still created with whatever data the catalog has.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// </summary> /// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId) private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{ {
try try
{ {
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId); var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
if (catalogItem == null) return null; if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an var coatingCategory = categories
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true. .Where(c => c.IsActive && c.IsCoating)
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating .OrderBy(c => c.DisplayOrder)
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase)) .FirstOrDefault();
?? categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower(); var vendorNameLower = catalogItem.VendorName.ToLower();
@@ -464,143 +437,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval", coatDto.PowderCostPerLb = null;
item.Id, item.Name, catalogItemId); _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id; return item.Id;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link", _logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
catalogItemId); coatDto.CatalogItemId);
return null; return null;
} }
} }
/// <summary>
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
/// on the first save only — Option B means the user owns the price after creation.
///
/// Coat types that qualify:
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0 (user-entered)
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
/// pre-filled from catalog unit price (inventory creation deferred to approval)
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
/// </summary>
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var itemDto in itemDtos)
{
if (itemDto.Coats == null) continue;
foreach (var coat in itemDto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
colorNames.Add(colorName);
}
}
}
}
if (totalCost <= 0) return null;
var uniqueColors = colorNames
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var description = uniqueColors.Any()
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
: "Custom Powder Order";
return new QuoteItem
{
QuoteId = quoteId,
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost,
UnitPrice = totalCost,
TotalPrice = totalCost,
ItemMaterialCost = totalCost,
CompanyId = companyId,
CreatedAt = createdAtUtc,
Coats = [],
PrepServices = []
};
}
/// <summary>
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
/// new (or existing) inventory record.
///
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
/// only reflects powders the shop is actually going to process.
///
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
/// same InventoryItemId — no duplicate records are created.
///
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
/// on an already-approved quote (e.g. retry after a transient error) is safe.
/// </summary>
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
{
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
false,
qi => qi.Coats);
var pendingCoats = quoteItems
.SelectMany(qi => qi.Coats)
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
.ToList();
if (pendingCoats.Count == 0) return;
// Group by catalog item ID so each unique powder generates exactly one inventory record.
var groups = pendingCoats
.GroupBy(c => c.PowderCatalogItemId!.Value)
.ToList();
foreach (var group in groups)
{
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
if (newInventoryId == null) continue;
// Link every coat in this group to the single newly-created inventory record.
foreach (var coat in group)
{
coat.InventoryItemId = newInventoryId;
coat.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
}
}
await _unitOfWork.SaveChangesAsync();
}
} }
@@ -73,9 +73,6 @@ public class ApplicationUser : IdentityUser
// Passkey enrollment prompt // Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false; public bool PasskeyPromptDismissed { get; set; } = false;
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
public string? KioskPin { get; set; }
// Ban // Ban
public bool IsBanned { get; set; } = false; public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; } public DateTime? BannedAt { get; set; }
@@ -133,15 +133,6 @@ public class Company : BaseEntity
/// </summary> /// </summary>
public string? KioskActivationToken { get; set; } public string? KioskActivationToken { get; set; }
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
public bool TimeclockEnabled { get; set; } = true;
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
public int? TimeclockAutoClockOutHours { get; set; }
// Navigation Properties // Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>(); public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>(); public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -1,53 +0,0 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// A per-company reusable pricing formula template. Users define named input fields and an
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
/// </summary>
public class CustomItemTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</summary>
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
public string FieldsJson { get; set; } = "[]";
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
public string Formula { get; set; } = string.Empty;
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
public decimal? DefaultRate { get; set; }
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
/// Shown in the template editor and quote wizard so users know which measurements to take.
/// Path format: {companyId}/{templateId}/diagram.{ext}
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Community library tracking ─────────────────────────────────────────
/// <summary>
/// Set when this template was imported from the community library.
/// Null for originally created templates.
/// </summary>
public int? SourceFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
/// <summary>
/// True once the user edits an imported template. Only modified imports (and original
/// creations) are eligible to be shared back to the community library.
/// </summary>
public bool IsModifiedFromSource { get; set; }
}
@@ -1,32 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Facility-level clock-in/clock-out record for an employee.
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
/// </summary>
public class EmployeeClockEntry : BaseEntity
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
/// <summary>Null means the employee is currently clocked in.</summary>
public DateTime? ClockOutTime { get; set; }
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
public decimal? HoursWorked { get; set; }
/// <summary>
/// Whether this segment is regular work time, a break, or a lunch period.
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
/// </summary>
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public virtual ApplicationUser User { get; set; } = null!;
}
+1 -1
View File
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
public string? Location { get; set; } public string? Location { get; set; }
// Maintenance Information // Maintenance Information
public int? RecommendedMaintenanceIntervalDays { get; set; } public int RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
@@ -1,19 +0,0 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
/// same item overwrites the existing row rather than creating a duplicate.
/// </summary>
public class FormulaLibraryImport : BaseEntity
{
public int FormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
public string ImportedByUserId { get; set; } = string.Empty;
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
public int ResultingCustomItemTemplateId { get; set; }
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
}
@@ -1,70 +0,0 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-level community library entry for a shared custom formula template.
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
/// Shared voluntarily by the originating company; imported as independent copies by others.
/// </summary>
public class FormulaLibraryItem
{
public int Id { get; set; }
// ── Formula content (copied from CustomItemTemplate at share time) ─────
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
public string FieldsJson { get; set; } = "[]";
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
/// <summary>
/// Blob path referencing the source template's diagram image.
/// Nulled out (here and on all imports) if the source template's diagram is removed.
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Attribution ────────────────────────────────────────────────────────
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
public string? Tags { get; set; }
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
public string? IndustryHint { get; set; }
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
public int SourceCustomItemTemplateId { get; set; }
public int SourceCompanyId { get; set; }
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
public string SourceCompanyName { get; set; } = string.Empty;
/// <summary>
/// When non-null, this entry was derived from an imported formula that was subsequently
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
/// </summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? InspiredBy { get; set; }
public string SharedByUserId { get; set; } = string.Empty;
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
/// <summary>False when the creator has removed it from the community library.</summary>
public bool IsPublished { get; set; } = true;
/// <summary>Running count of how many companies have imported this entry.</summary>
public int ImportCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
@@ -1,24 +0,0 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// One thumbs-up or thumbs-down vote per company per library formula.
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
/// </summary>
public class FormulaLibraryRating
{
public int Id { get; set; }
public int FormulaLibraryItemId { get; set; }
/// <summary>The company casting the vote.</summary>
public int CompanyId { get; set; }
/// <summary>True = thumbs up, false = thumbs down.</summary>
public bool IsPositive { get; set; }
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
}
@@ -12,5 +12,4 @@ public class InventoryCategoryLookup : BaseEntity
// Relationships // Relationships
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>(); public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
} }
@@ -52,14 +52,6 @@ public class JobItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; } public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships // Relationships
public virtual Job Job { get; set; } = null!; public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
@@ -56,14 +56,6 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; } public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships // Relationships
public virtual Quote Quote { get; set; } = null!; public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
@@ -15,13 +15,6 @@ public class QuoteItemCoat : BaseEntity
// Powder selection (same pattern as current QuoteItem) // Powder selection (same pattern as current QuoteItem)
public int? InventoryItemId { get; set; } // In-stock powder public int? InventoryItemId { get; set; } // In-stock powder
/// <summary>
/// Platform powder catalog item that this coat was sourced from.
/// Persisted so that at quote-approval time the system can create exactly one
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
/// than creating during quote-save when the job may never be approved.
/// </summary>
public int? PowderCatalogItemId { get; set; }
public string? ColorName { get; set; } // Color name public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc. public string? ColorCode { get; set; } // RAL code, etc.
@@ -31,9 +31,6 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Pricing attribution for the linked rework job (null on pre-existing records)
public ReworkPricingType? ReworkPricingType { get; set; }
// ── Resolution ──────────────────────────────────────────────────────────── // ── Resolution ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open; public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; } public ReworkResolution? Resolution { get; set; }
@@ -52,9 +52,6 @@ public class SubscriptionPlanConfig : BaseEntity
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary> /// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
public bool AllowSms { get; set; } = false; public bool AllowSms { get; set; } = false;
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
public bool AllowCustomFormulas { get; set; } = false;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
@@ -45,7 +45,6 @@ public class Vendor : BaseEntity
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>(); public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>(); public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; } public virtual Account? DefaultExpenseAccount { get; set; }
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
} }
public class InventoryTransaction : BaseEntity public class InventoryTransaction : BaseEntity
@@ -1,22 +0,0 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents an activated shop-floor kiosk tablet for the timeclock.
/// One row per device; multiple rows per company are supported so shops can have
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
/// device-specific cookie and validated on every kiosk request.
/// </summary>
public class TimeclockKioskDevice : BaseEntity
{
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
public string? DeviceName { get; set; }
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
public string Token { get; set; } = string.Empty;
/// <summary>UTC timestamp when a manager activated this device.</summary>
public DateTime ActivatedAt { get; set; }
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
public DateTime? LastSeenAt { get; set; }
}
-8
View File
@@ -144,14 +144,6 @@ public enum ReworkResolution
NoActionRequired = 4 NoActionRequired = 4
} }
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
public enum ReworkPricingType
{
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
CustomerFull = 2 // Customer caused it; full original pricing applies
}
public enum BugReportStatus public enum BugReportStatus
{ {
New = 0, New = 0,
@@ -1,17 +0,0 @@
namespace PowderCoating.Core.Enums;
/// <summary>
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
/// </summary>
public enum ClockEntryType
{
/// <summary>Normal productive work time (default).</summary>
Work = 0,
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
Break = 1,
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
Lunch = 2
}
@@ -155,18 +155,6 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Customer Intake Kiosk // Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; } IRepository<KioskSession> KioskSessions { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
// Formula Community Library
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
// Employee Timeclock
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
Task<int> SaveChangesAsync(); Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -92,10 +92,4 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day. /// were never completed and rolled past their scheduled day.
/// </summary> /// </summary>
Task<List<Job>> GetOverdueScheduledJobsAsync(); Task<List<Job>> GetOverdueScheduledJobsAsync();
/// <summary>
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
/// </summary>
Task<int> GetReworkJobCountAsync(int originalJobId);
} }
@@ -289,15 +289,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// </summary> /// </summary>
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; } public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary> /// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
public DbSet<BugReport> BugReports { get; set; } public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary> /// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -383,17 +374,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary> /// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; } public DbSet<KioskSession> KioskSessions { get; set; }
// Custom Formula Templates
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
// Employee Timeclock
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
/// <summary> /// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants. /// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly. /// No global query filter — SuperAdmin controllers query this directly.
@@ -787,32 +767,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(k => k.LinkedJobId) .HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// Custom Formula Templates — tenant-filtered + soft delete
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Employee Timeclock — tenant-filtered + soft delete
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
modelBuilder.Entity<EmployeeClockEntry>()
.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Restrict);
// Composite index for "who's clocked in today" and date-range attendance reports
modelBuilder.Entity<EmployeeClockEntry>()
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
// Timeclock kiosk devices — one row per activated tablet per company
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.Token).IsUnique();
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.CompanyId);
// Account self-referencing hierarchy // Account self-referencing hierarchy
modelBuilder.Entity<Account>() modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount) .HasOne(a => a.ParentAccount)
@@ -855,15 +809,6 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(s => s.DefaultExpenseAccountId) .HasForeignKey(s => s.DefaultExpenseAccountId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
modelBuilder.Entity<Vendor>()
.HasMany(v => v.Categories)
.WithMany(c => c.Vendors)
.UsingEntity<Dictionary<string, object>>(
"VendorInventoryCategories",
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
// Bill → APAccount (no cascade to avoid cycles) // Bill → APAccount (no cascade to avoid cycles)
modelBuilder.Entity<Bill>() modelBuilder.Entity<Bill>()
.HasOne(b => b.APAccount) .HasOne(b => b.APAccount)
@@ -2083,61 +2028,6 @@ modelBuilder.Entity<Job>()
.HasIndex(c => new { c.CompanyId, c.MemoNumber }) .HasIndex(c => new { c.CompanyId, c.MemoNumber })
.IsUnique() .IsUnique()
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber"); .HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
modelBuilder.Entity<FormulaLibraryItem>()
.HasOne(f => f.InspiredBy)
.WithMany()
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.SourceCompanyId)
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.IsPublished)
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.FormulaLibraryItem)
.WithMany()
.HasForeignKey(i => i.FormulaLibraryItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.ResultingCustomItemTemplate)
.WithMany()
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
// FormulaLibraryRating — platform-level; one vote per company per formula
modelBuilder.Entity<FormulaLibraryRating>()
.HasOne(r => r.FormulaLibraryItem)
.WithMany()
.HasForeignKey(r => r.FormulaLibraryItemId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<FormulaLibraryRating>()
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
modelBuilder.Entity<CustomItemTemplate>()
.HasOne(t => t.SourceFormulaLibraryItem)
.WithMany()
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
} }
/// <summary> /// <summary>
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddReworkPricingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ReworkPricingType",
table: "ReworkRecords",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReworkPricingType",
table: "ReworkRecords");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,93 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddVendorCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VendorInventoryCategories",
columns: table => new
{
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
table.ForeignKey(
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
column: x => x.InventoryCategoryLookupId,
principalTable: "InventoryCategoryLookups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VendorInventoryCategories_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
migrationBuilder.CreateIndex(
name: "IX_VendorInventoryCategories_VendorId",
table: "VendorInventoryCategories",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VendorInventoryCategories");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,197 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomItemTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "QuoteItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "QuoteItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "QuoteItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "JobItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "JobItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "JobItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "CustomItemTemplates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
DisplayOrder = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomItemTemplates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
migrationBuilder.CreateIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId");
migrationBuilder.AddForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropTable(
name: "CustomItemTemplates");
migrationBuilder.DropIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "JobItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "JobItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
@@ -1,79 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MakeMaintenanceIntervalNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAllowCustomFormulas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowCustomFormulas",
table: "SubscriptionPlanConfigs",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowCustomFormulas",
table: "SubscriptionPlanConfigs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,115 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddEmployeeTimeclock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskPin",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "EmployeeClockEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
table.ForeignKey(
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
table: "EmployeeClockEntries",
columns: new[] { "CompanyId", "ClockInTime" });
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_UserId",
table: "EmployeeClockEntries",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployeeClockEntries");
migrationBuilder.DropColumn(
name: "KioskPin",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTimeclockKioskToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TimeclockKioskToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TimeclockKioskToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,141 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTimeclockSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TimeclockKioskToken",
table: "Companies");
migrationBuilder.AddColumn<bool>(
name: "TimeclockAllowMultiplePunchesPerDay",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "TimeclockAutoClockOutHours",
table: "Companies",
type: "int",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TimeclockEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "TimeclockKioskDevices",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TimeclockKioskDevices", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
migrationBuilder.CreateIndex(
name: "IX_TimeclockKioskDevices_CompanyId",
table: "TimeclockKioskDevices",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_TimeclockKioskDevices_Token",
table: "TimeclockKioskDevices",
column: "Token",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TimeclockKioskDevices");
migrationBuilder.DropColumn(
name: "TimeclockAllowMultiplePunchesPerDay",
table: "Companies");
migrationBuilder.DropColumn(
name: "TimeclockAutoClockOutHours",
table: "Companies");
migrationBuilder.DropColumn(
name: "TimeclockEnabled",
table: "Companies");
migrationBuilder.AddColumn<string>(
name: "TimeclockKioskToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddClockEntryType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EntryType",
table: "EmployeeClockEntries",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EntryType",
table: "EmployeeClockEntries");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
}
}
}
@@ -1,71 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogItemIdToCoat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PowderCatalogItemId",
table: "QuoteItemCoats",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PowderCatalogItemId",
table: "QuoteItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,214 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFormulaLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsModifiedFromSource",
table: "CustomItemTemplates",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "FormulaLibraryItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsPublished = table.Column<bool>(type: "bit", nullable: false),
ImportCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
column: x => x.InspiredByFormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "FormulaLibraryImports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
column: x => x.ResultingCustomItemTemplateId,
principalTable: "CustomItemTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
column: x => x.FormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
migrationBuilder.CreateIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_Company_Item",
table: "FormulaLibraryImports",
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
table: "FormulaLibraryImports",
column: "FormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
table: "FormulaLibraryImports",
column: "ResultingCustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
table: "FormulaLibraryItems",
column: "InspiredByFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_IsPublished",
table: "FormulaLibraryItems",
column: "IsPublished");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_SourceCompanyId",
table: "FormulaLibraryItems",
column: "SourceCompanyId");
migrationBuilder.AddForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId",
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropTable(
name: "FormulaLibraryImports");
migrationBuilder.DropTable(
name: "FormulaLibraryItems");
migrationBuilder.DropIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "IsModifiedFromSource",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFormulaLibraryRatings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FormulaLibraryRatings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
IsPositive = table.Column<bool>(type: "bit", nullable: false),
RatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryRatings", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryRatings_FormulaLibraryItems_FormulaLibraryItemId",
column: x => x.FormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryRatings_Item_Company",
table: "FormulaLibraryRatings",
columns: new[] { "FormulaLibraryItemId", "CompanyId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FormulaLibraryRatings");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
}
}
}
@@ -556,9 +556,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned") b.Property<bool>("IsBanned")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<string>("KioskPin")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("LaborCostPerHour") b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -1926,15 +1923,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool>("TimeclockAllowMultiplePunchesPerDay")
.HasColumnType("bit");
b.Property<int?>("TimeclockAutoClockOutHours")
.HasColumnType("int");
b.Property<bool>("TimeclockEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -2662,88 +2650,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CreditMemoApplications"); b.ToTable("CreditMemoApplications");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsModifiedFromSource")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OutputMode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SourceFormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("SourceFormulaLibraryItemId");
b.ToTable("CustomItemTemplates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3054,66 +2960,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Deposits"); b.ToTable("Deposits");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ClockInTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ClockOutTime")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("EntryType")
.HasColumnType("int");
b.Property<decimal?>("HoursWorked")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("CompanyId", "ClockInTime");
b.ToTable("EmployeeClockEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3199,7 +3045,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("PurchasePrice") b.Property<decimal>("PurchasePrice")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int?>("RecommendedMaintenanceIntervalDays") b.Property<int>("RecommendedMaintenanceIntervalDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("SerialNumber") b.Property<string>("SerialNumber")
@@ -3445,183 +3291,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("FixedAssetDepreciationEntries"); b.ToTable("FixedAssetDepreciationEntries");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("FormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime>("ImportedAt")
.HasColumnType("datetime2");
b.Property<string>("ImportedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ResultingCustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("FormulaLibraryItemId");
b.HasIndex("ResultingCustomItemTemplateId");
b.HasIndex("CompanyId", "FormulaLibraryItemId")
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
b.ToTable("FormulaLibraryImports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("ImportCount")
.HasColumnType("int");
b.Property<string>("IndustryHint")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InspiredByFormulaLibraryItemId")
.HasColumnType("int");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OutputMode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("SharedAt")
.HasColumnType("datetime2");
b.Property<string>("SharedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCompanyId")
.HasColumnType("int");
b.Property<string>("SourceCompanyName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCustomItemTemplateId")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("InspiredByFormulaLibraryItemId");
b.HasIndex("IsPublished")
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
b.HasIndex("SourceCompanyId")
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
b.ToTable("FormulaLibraryItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<int>("FormulaLibraryItemId")
.HasColumnType("int");
b.Property<bool>("IsPositive")
.HasColumnType("bit");
b.Property<DateTime>("RatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("FormulaLibraryItemId", "CompanyId")
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
b.ToTable("FormulaLibraryRatings");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -4804,9 +4473,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -4823,18 +4489,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -4898,8 +4558,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("JobId") b.HasIndex("JobId")
.HasDatabaseName("IX_JobItems_JobId"); .HasDatabaseName("IX_JobItems_JobId");
@@ -7053,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7064,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7075,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382), CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7602,9 +7260,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -7618,18 +7273,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("EstimatedMinutes") b.Property<int>("EstimatedMinutes")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -7699,8 +7348,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("QuoteId") b.HasIndex("QuoteId")
.HasDatabaseName("IX_QuoteItems_QuoteId"); .HasDatabaseName("IX_QuoteItems_QuoteId");
@@ -7767,9 +7414,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<decimal?>("PowderCostPerLb") b.Property<decimal?>("PowderCostPerLb")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -8346,9 +7990,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId") b.Property<int?>("ReworkJobId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType") b.Property<int>("ReworkType")
.HasColumnType("int"); .HasColumnType("int");
@@ -8432,9 +8073,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowAiPhotoQuotes") b.Property<bool>("AllowAiPhotoQuotes")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("AllowCustomFormulas")
.HasColumnType("bit");
b.Property<bool>("AllowOnlinePayments") b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -8611,61 +8249,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TermsAcceptances"); b.ToTable("TermsAcceptances");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.TimeclockKioskDevice", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("DeviceName")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("datetime2");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.HasIndex("Token")
.IsUnique();
b.ToTable("TimeclockKioskDevices");
});
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b => modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -9048,21 +8631,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("YearEndCloses"); b.ToTable("YearEndCloses");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.Property<int>("InventoryCategoryLookupId")
.HasColumnType("int");
b.Property<int>("VendorId")
.HasColumnType("int");
b.HasKey("InventoryCategoryLookupId", "VendorId");
b.HasIndex("VendorId");
b.ToTable("VendorInventoryCategories");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -9448,16 +9016,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Invoice"); b.Navigation("Invoice");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
.WithMany()
.HasForeignKey("SourceFormulaLibraryItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SourceFormulaLibraryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9521,17 +9079,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("RecordedBy"); b.Navigation("RecordedBy");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9614,46 +9161,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
.WithMany()
.HasForeignKey("FormulaLibraryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
.WithMany()
.HasForeignKey("ResultingCustomItemTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FormulaLibraryItem");
b.Navigation("ResultingCustomItemTemplate");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
.WithMany()
.HasForeignKey("InspiredByFormulaLibraryItemId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("InspiredBy");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
.WithMany()
.HasForeignKey("FormulaLibraryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FormulaLibraryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy") b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -9987,10 +9494,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("CatalogItemId") .HasForeignKey("CatalogItemId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Job", "Job") b.HasOne("PowderCoating.Core.Entities.Job", "Job")
.WithMany("JobItems") .WithMany("JobItems")
.HasForeignKey("JobId") .HasForeignKey("JobId")
@@ -10001,8 +9504,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Job"); b.Navigation("Job");
}); });
@@ -10612,10 +10113,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany() .WithMany()
.HasForeignKey("CatalogItemId"); .HasForeignKey("CatalogItemId");
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
.WithMany("QuoteItems") .WithMany("QuoteItems")
.HasForeignKey("QuoteId") .HasForeignKey("QuoteId")
@@ -10626,8 +10123,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Quote"); b.Navigation("Quote");
}); });
@@ -10874,21 +10369,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
.WithMany()
.HasForeignKey("InventoryCategoryLookupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
{ {
b.Navigation("BillLineItems"); b.Navigation("BillLineItems");
@@ -21,7 +21,6 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" /> <PackageReference Include="Stripe.net" Version="50.4.1" />
@@ -187,14 +187,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync() public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{ {
@@ -123,18 +123,6 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk // Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions; private IRepository<KioskSession>? _kioskSessions;
// Employee Timeclock
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
// Formula Community Library
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
// Purchase Orders // Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders; private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems; private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -469,30 +457,6 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions => public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context); _kioskSessions ??= new Repository<KioskSession>(_context);
/// <summary>Repository for <see cref="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
/// <summary>Repository for <see cref="FormulaLibraryRating"/> per-company thumbs votes; platform-level, no tenant filter.</summary>
public IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings =>
_formulaLibraryRatings ??= new PlainRepository<FormulaLibraryRating>(_context);
// Job Templates // Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates => public IJobTemplateRepository JobTemplates =>
@@ -262,7 +262,6 @@ public class CsvImportService : ICsvImportService
JobNumber = "JOB-2601-0001", JobNumber = "JOB-2601-0001",
CustomerEmail = "customer@example.com", CustomerEmail = "customer@example.com",
CustomerName = "Acme Corp (used if email is blank or not found)", CustomerName = "Acme Corp (used if email is blank or not found)",
Description = "Sample job description",
Status = "Pending", Status = "Pending",
Priority = "Normal", Priority = "Normal",
ScheduledDate = DateTime.Today.AddDays(7), ScheduledDate = DateTime.Today.AddDays(7),
@@ -270,7 +269,7 @@ public class CsvImportService : ICsvImportService
FinalPrice = 750.00m, FinalPrice = 750.00m,
CustomerPO = "PO-12345", CustomerPO = "PO-12345",
SpecialInstructions = "Handle with care", SpecialInstructions = "Handle with care",
Notes = "Internal notes" Notes = "Sample job"
}); });
csv.NextRecord(); csv.NextRecord();
@@ -389,15 +388,8 @@ public class CsvImportService : ICsvImportService
/// Imports customers from a CSV stream and persists valid rows to the database for the given company. /// Imports customers from a CSV stream and persists valid rows to the database for the given company.
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated /// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
/// entity is saved individually so that a single bad row does not roll back the entire batch. /// entity is saved individually so that a single bad row does not roll back the entire batch.
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous /// Duplicate detection runs against both existing DB records (by email) and within the import file
/// identifier is absent: /// itself, catching cases where the same email appears twice in one upload.
/// Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
/// earlier batch row the row is skipped.
/// Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
/// phone prevents false positives when two people share a number (e.g. a family).
/// Row is skipped on match.
/// Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
/// Location data is imprecise so this emits a warning but still imports the row.
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the /// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
/// customer is imported without a tier rather than being skipped entirely. /// customer is imported without a tier rather than being skipped entirely.
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a /// Contact names are split on the first space into FirstName / LastName because the CSV carries a
@@ -426,53 +418,15 @@ public class CsvImportService : ICsvImportService
// Get all existing customers for duplicate detection // Get all existing customers for duplicate detection
var existingCustomers = await _unitOfWork.Customers.GetAllAsync(); var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
// Tier 1 lookup: email → existing customer
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email)) var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase); .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
// Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
// Combining name with phone avoids false positives when two people share a number.
var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in existingCustomers)
{
var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
if (phone == null) continue;
var name = string.IsNullOrWhiteSpace(c.CompanyName)
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
: c.CompanyName;
var key = $"{phone}|{name}";
if (!existingByPhoneAndName.ContainsKey(key))
existingByPhoneAndName[key] = c;
}
// Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
// Only populated when a customer has at least one location field so the key isn't trivially blank.
var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in existingCustomers)
{
if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
continue;
var name = string.IsNullOrWhiteSpace(c.CompanyName)
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
: c.CompanyName;
if (string.IsNullOrWhiteSpace(name)) continue;
var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
if (!existingByNameAndLocation.ContainsKey(key))
existingByNameAndLocation[key] = c;
}
// Get pricing tiers for lookup // Get pricing tiers for lookup
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync(); var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase); var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>(); var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
// Within-batch tracking sets (prevent duplicate detection against rows already queued)
var batchEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var batchPhoneAndName = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var batchNameAndLocation = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var record in records) foreach (var record in records)
{ {
rowNumber++; rowNumber++;
@@ -480,12 +434,7 @@ public class CsvImportService : ICsvImportService
{ {
// Strip any literal quote characters that QB/Excel may wrap around field values // Strip any literal quote characters that QB/Excel may wrap around field values
var cleanCompanyName = StripQuotes(record.CompanyName); var cleanCompanyName = StripQuotes(record.CompanyName);
// Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email) var cleanEmail = StripQuotes(record.Email);
// uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
// but "" (empty string) is not NULL and would cause a unique-constraint violation
// on the second blank-email customer saved.
var rawEmail = StripQuotes(record.Email);
var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
var firstName = StripQuotes(record.ContactFirstName)?.Trim(); var firstName = StripQuotes(record.ContactFirstName)?.Trim();
var lastName = StripQuotes(record.ContactLastName)?.Trim(); var lastName = StripQuotes(record.ContactLastName)?.Trim();
@@ -502,68 +451,20 @@ public class CsvImportService : ICsvImportService
cleanCompanyName = derivedName; cleanCompanyName = derivedName;
} }
// Canonical display name used as part of composite keys in Tiers 2 and 3 // Check for duplicate email in existing data
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName) if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
? $"{firstName} {lastName}".Trim()
: cleanCompanyName;
// --- Tier 1: email dedup ---
// Only engaged when the row has an email address.
if (!string.IsNullOrEmpty(cleanEmail))
{ {
if (existingEmails.ContainsKey(cleanEmail.ToLower())) result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
{ result.SkippedCount++;
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping."); continue;
result.SkippedCount++;
continue;
}
if (batchEmails.Contains(cleanEmail))
{
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
result.SkippedCount++;
continue;
}
} }
else
// Check for duplicate email within the import batch
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
{ {
// --- Tier 2: name + phone composite dedup (email absent) --- result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
// Requiring both name and phone to match avoids false positives when two result.SkippedCount++;
// unrelated people happen to share a phone number (e.g. a shared office line). continue;
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
if (normalizedPhone != null)
{
var phoneNameKey = $"{normalizedPhone}|{displayName}";
if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
result.SkippedCount++;
continue;
}
if (batchPhoneAndName.Contains(phoneNameKey))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
result.SkippedCount++;
continue;
}
}
else
{
// --- Tier 3: name + location composite warning (no email, no phone) ---
// Location data is imprecise so we warn but still import — a name + city
// collision across unrelated people is plausible enough not to hard-skip.
var city = record.City?.Trim();
var state = record.State?.Trim();
var zip = record.ZipCode?.Trim();
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
{
var locationKey = $"{displayName}|{city}|{state}|{zip}";
if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
}
}
}
} }
// Resolve pricing tier // Resolve pricing tier
@@ -611,29 +512,6 @@ public class CsvImportService : ICsvImportService
}; };
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty)); customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
// Register in batch tracking so later rows are checked against this one
if (!string.IsNullOrEmpty(cleanEmail))
{
batchEmails.Add(cleanEmail);
}
else
{
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
if (normalizedPhone != null)
{
batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
}
else
{
var city = record.City?.Trim();
var state = record.State?.Trim();
var zip = record.ZipCode?.Trim();
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1390,22 +1268,24 @@ public class CsvImportService : ICsvImportService
MissingFieldFound = null MissingFieldFound = null
}); });
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice) var records = csv.GetRecords<JobImportDto>().ToList();
// as null rather than throwing a fatal TypeConverterException. result.TotalRows = records.Count;
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
// Read header row first so we know field count before iterating rows. _logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
await csv.ReadAsync();
csv.ReadHeader();
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads. // Get all existing jobs for duplicate detection
var existingJobs = await _unitOfWork.Jobs.GetAllAsync(); var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)) var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase); .ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
// Get customers for lookup — build two dictionaries so we can resolve by email
// first and fall back to company name when the customer has no email on file.
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.GetAllAsync();
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email)) var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase); .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
// TryAdd ensures that if two customers share the same name the first one wins and the
// lookup warning will prompt the user to resolve the ambiguity manually.
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase); var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in customers) foreach (var c in customers)
{ {
@@ -1416,42 +1296,19 @@ public class CsvImportService : ICsvImportService
customerByName.TryAdd(name, c); customerByName.TryAdd(name, c);
} }
// Get job statuses for lookup
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase); var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
// Get job priorities for lookup
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase); var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>(); var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false" foreach (var record in records)
// in a decimal field) is caught per-row rather than aborting the entire import.
while (await csv.ReadAsync())
{ {
result.TotalRows++; rowNumber++;
JobImportDto record;
try
{
record = csv.GetRecord<JobImportDto>()
?? throw new InvalidOperationException("Row returned null record.");
}
catch (Exception parseEx)
{
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
result.ErrorCount++;
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
continue;
}
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
// Warn when FinalPrice was non-numeric (lenient converter returned null).
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
{
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
}
try try
{ {
// Validate required fields // Validate required fields
@@ -1557,9 +1414,7 @@ public class CsvImportService : ICsvImportService
CustomerPO = record.CustomerPO?.Trim(), CustomerPO = record.CustomerPO?.Trim(),
SpecialInstructions = record.SpecialInstructions?.Trim(), SpecialInstructions = record.SpecialInstructions?.Trim(),
InternalNotes = record.Notes?.Trim(), InternalNotes = record.Notes?.Trim(),
Description = record.Description?.Trim() Description = record.SpecialInstructions?.Trim() ?? "Imported job",
?? record.SpecialInstructions?.Trim()
?? "Imported job",
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
}; };
@@ -2958,23 +2813,6 @@ public class CsvImportService : ICsvImportService
return trimmed; return trimmed;
} }
/// <summary>
/// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
/// Stripping to digits only means formatting differences such as (423)&nbsp;331-9834,
/// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
/// contains fewer than 7 digits — too short to be a real phone number and avoids false
/// positive matches on placeholder values like "N/A" or extension-only strings.
/// </summary>
/// <param name="phone">Raw phone string as read from the CSV, or null.</param>
/// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
private static string? NormalizePhone(string? phone)
{
if (string.IsNullOrWhiteSpace(phone)) return null;
var digits = new string(phone.Where(char.IsDigit).ToArray());
if (digits.Length < 7) return null;
return digits.Length >= 10 ? digits[^10..] : digits;
}
// ── Invoice Import ─────────────────────────────────────────────────────────── // ── Invoice Import ───────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -3502,23 +3340,4 @@ public class CsvImportService : ICsvImportService
return result; return result;
} }
} }
/// <summary>
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
/// artefacts like "false" in a price column are treated as $0 with a warning.
/// </summary>
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
{
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
{
if (string.IsNullOrWhiteSpace(text)) return null;
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
? (object?)v
: null;
}
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
=> value?.ToString();
}
} }
@@ -1,247 +0,0 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using NCalc2;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Generates NCalc pricing formula templates from natural-language descriptions using
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
/// shape being estimated. The model returns a structured JSON object containing the
/// field list, NCalc expression, output mode, and verification inputs; the service
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
/// </summary>
public class CustomFormulaAiService : ICustomFormulaAiService
{
private readonly IConfiguration _config;
private readonly ILogger<CustomFormulaAiService> _logger;
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
from user-supplied field values.
CRITICAL: NCalc function names are CASE-SENSITIVE and must be ALL LOWERCASE.
Supported built-in functions (always write these exactly as shown):
if(condition, trueValue, falseValue) conditional expression
abs(x) absolute value
round(x, digits) round to N decimal places
max(a, b) larger of two values
min(a, b) smaller of two values
pow(base, exponent) exponentiation
sqrt(x) square root
Standard operators: + - * / %
Comparison operators: < > <= >= == !=
Boolean operators: && || !
Do NOT use: IF, Abs, Round, Max, Min, Pow, Sqrt (uppercase versions) NCalc will reject them.
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
'Tubular frame') and you must produce a pricing formula template.
Respond ONLY with a valid JSON object matching this exact schema no markdown, no explanation:
{
""name"": ""string short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
""fields"": [
{
""name"": ""snake_case_variable_name"",
""label"": ""Human-readable label"",
""unit"": ""in / ft / mm / cm / qty / lbs or empty string"",
""defaultValue"": number
}
],
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
""defaultRate"": number or null,
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' null if no rate"",
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
""verificationInputs"": { ""variable_name"": number },
""verificationResult"": number
}
Built-in shop-rate variables (injected automatically at eval time do NOT redeclare them as fields):
standard_labor_rate shop billing rate in $/hr (e.g. hours * standard_labor_rate)
additional_coat_labor_pct extra-coat labor surcharge 0100 (e.g. cost * (1 + additional_coat_labor_pct/100))
markup_pct general markup percentage 0100 (e.g. cost * (1 + markup_pct/100))
Rules:
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
- Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array they are injected automatically
- verificationInputs and verificationResult must use the exact field names and formula you generated
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
- For inch inputs convert to sqft: divide by 144 (sqinsqft) or use /12 per side before multiplying
";
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
{
_config = config;
_logger = logger;
}
/// <inheritdoc />
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new GenerateFormulaFromAiResponse
{
Success = false,
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
try
{
var client = new AnthropicClient(apiKey);
var userContent = new List<ContentBase>();
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
{
userContent.Add(new ImageContent
{
Source = new ImageSource
{
MediaType = imageContentType,
Data = Convert.ToBase64String(imageBytes)
}
});
}
userContent.Add(new TextContent { Text = request.Description });
var messages = new List<Message>
{
new() { Role = RoleType.User, Content = userContent }
};
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = SystemPrompt,
Messages = messages
});
var rawJson = response.Message.ToString().Trim();
// Strip markdown code fences if the model adds them
if (rawJson.StartsWith("```"))
{
var start = rawJson.IndexOf('\n') + 1;
var end = rawJson.LastIndexOf("```");
if (end > start) rawJson = rawJson[start..end].Trim();
}
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
? fieldsEl.GetRawText()
: "[]";
decimal? defaultRate = null;
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
defaultRate = rateEl.GetDecimal();
decimal? verificationResult = null;
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
verificationResult = vrEl.GetDecimal();
string? verificationInputs = null;
if (root.TryGetProperty("verificationInputs", out var viEl))
verificationInputs = viEl.GetRawText();
return new GenerateFormulaFromAiResponse
{
Success = true,
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
FieldsJson = fieldsJson,
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
DefaultRate = defaultRate,
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
VerificationResult = verificationResult,
VerificationInputs = verificationInputs
};
}
catch (Exception ex)
{
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
}
}
// Lowercase NCalc built-in names before evaluation so that user-typed or AI-generated
// uppercase variants (IF, Abs, POW, etc.) don't produce "Function not found" errors.
private static readonly Regex _ncalcFuncRegex = new(
@"\b(if|abs|round|max|min|pow|sqrt|ceiling|floor|truncate|sign|log|exp)\b(?=\s*\()",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string NormalizeFormula(string formula) =>
_ncalcFuncRegex.Replace(formula, m => m.Value.ToLowerInvariant());
/// <inheritdoc />
public (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula)
{
if (string.IsNullOrWhiteSpace(formula))
return (formula, "Formula cannot be empty.");
var normalized = NormalizeFormula(formula);
try
{
var expr = new Expression(normalized);
if (expr.HasErrors())
return (formula, expr.Error);
return (normalized, null);
}
catch (Exception ex)
{
return (formula, ex.Message);
}
}
/// <inheritdoc />
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Formula))
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
try
{
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
request.VariablesJson ?? "{}") ?? new();
var expr = new Expression(NormalizeFormula(request.Formula));
foreach (var kv in variables)
{
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
? (object)kv.Value.GetDouble()
: (object)(kv.Value.GetString() ?? "");
}
var result = expr.Evaluate();
var decResult = Convert.ToDecimal(result);
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
}
catch (Exception ex)
{
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
}
}
}
@@ -1,382 +0,0 @@
using AutoMapper;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Manages the community formula library: sharing a company template to the platform-wide
/// library, removing it, browsing published entries, and importing an entry as a local copy.
/// </summary>
public class FormulaLibraryService : IFormulaLibraryService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<FormulaLibraryService> _logger;
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <inheritdoc />
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId, string? search, string? outputMode, string? industryHint)
{
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
if (!string.IsNullOrWhiteSpace(search))
{
var lower = search.ToLowerInvariant();
items = items.Where(i =>
i.Name.ToLowerInvariant().Contains(lower) ||
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
}
if (!string.IsNullOrWhiteSpace(outputMode))
items = items.Where(i => i.OutputMode == outputMode);
if (!string.IsNullOrWhiteSpace(industryHint))
items = items.Where(i => i.IndustryHint != null &&
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
// Load InspiredBy for attribution line
var itemList = items.ToList();
var inspiredByIds = itemList
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
.Distinct()
.ToHashSet();
Dictionary<int, FormulaLibraryItem> inspirations = new();
foreach (var id in inspiredByIds)
{
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
if (parent != null) inspirations[id] = parent;
}
// Attach navigation properties manually (PlainRepository doesn't eager-load)
foreach (var item in itemList)
{
if (item.InspiredByFormulaLibraryItemId.HasValue &&
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
item.InspiredBy = parent;
}
// Determine which entries this company has already imported
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
imp => imp.CompanyId == companyId && !imp.IsDeleted);
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
// Load all ratings in one query for this page of items
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => allItemIds.Contains(r.FormulaLibraryItemId));
// Group counts and find the current company's vote per item
var ratingsByItem = allRatings
.GroupBy(r => r.FormulaLibraryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
for (int i = 0; i < dtos.Count; i++)
{
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
{
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
dtos[i].MyVote = myRating?.IsPositive;
}
}
// Sort: thumbs-up score descending, then import count, then name
return dtos
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
.ThenByDescending(d => d.ImportCount)
.ThenBy(d => d.Name);
}
/// <inheritdoc />
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished) return null;
if (item.InspiredByFormulaLibraryItemId.HasValue)
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
item.InspiredByFormulaLibraryItemId.Value);
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
dto.AlreadyImported = imp.Any();
return dto;
}
/// <inheritdoc />
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
if (template == null || template.CompanyId != companyId)
throw new InvalidOperationException("Template not found.");
if (!CanShare(template))
throw new InvalidOperationException("This template is not eligible to be shared.");
// Determine "Inspired by" — if this was imported from the library
int? inspiredById = null;
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
inspiredById = template.SourceFormulaLibraryItemId;
// Get company name for attribution
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyName = company?.CompanyName ?? "Unknown Company";
// Re-use existing row if one exists (re-share after unshare, or update after edits)
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
if (existing != null)
{
CopyFromTemplate(existing, template, companyName, request);
existing.InspiredByFormulaLibraryItemId = inspiredById;
existing.IsPublished = true;
existing.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
await _unitOfWork.CompleteAsync();
return existing.Id;
}
var libraryItem = new FormulaLibraryItem
{
SharedByUserId = userId,
SharedAt = DateTime.UtcNow,
SourceCustomItemTemplateId = template.Id,
SourceCompanyId = companyId,
SourceCompanyName = companyName,
InspiredByFormulaLibraryItemId = inspiredById,
IsPublished = true,
};
CopyFromTemplate(libraryItem, template, companyName, request);
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return libraryItem.Id;
}
/// <inheritdoc />
public async Task UnshareAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || item.SourceCompanyId != companyId) return;
item.IsPublished = false;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
await _unitOfWork.CompleteAsync();
}
/// <inheritdoc />
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
{
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (libraryItem == null || !libraryItem.IsPublished)
throw new InvalidOperationException("Library entry not found or no longer published.");
// Return existing import if already imported
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
var existingImport = existingImports.FirstOrDefault();
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
// Create a local copy as a new CustomItemTemplate
var template = new CustomItemTemplate
{
CompanyId = companyId,
Name = libraryItem.Name,
Description = libraryItem.Description,
OutputMode = libraryItem.OutputMode,
FieldsJson = libraryItem.FieldsJson,
Formula = libraryItem.Formula,
DefaultRate = libraryItem.DefaultRate,
RateLabel = libraryItem.RateLabel,
Notes = libraryItem.Notes,
DiagramImagePath = libraryItem.DiagramImagePath,
DisplayOrder = 0,
IsActive = true,
SourceFormulaLibraryItemId = libraryItemId,
IsModifiedFromSource = false,
};
await _unitOfWork.CustomItemTemplates.AddAsync(template);
await _unitOfWork.CompleteAsync();
var importRecord = new FormulaLibraryImport
{
CompanyId = companyId,
FormulaLibraryItemId = libraryItemId,
ImportedByUserId = userId,
ImportedAt = DateTime.UtcNow,
ResultingCustomItemTemplateId = template.Id,
};
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
// Increment import counter
libraryItem.ImportCount++;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return template.Id;
}
/// <inheritdoc />
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (template == null || template.CompanyId != companyId)
return new FormulaLibraryStatusDto { CanShare = false };
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
// Populate import attribution
if (template.SourceFormulaLibraryItemId.HasValue)
{
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
if (source != null)
{
dto.ImportedFromName = source.Name;
dto.ImportedFromCompany = source.SourceCompanyName;
}
}
// Check if this template has an active library entry
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
if (libraryItem != null)
{
dto.LibraryItemId = libraryItem.Id;
dto.IsPublished = libraryItem.IsPublished;
}
return dto;
}
/// <inheritdoc />
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
{
// Null out on the library item published from this template
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
if (libraryItem != null && libraryItem.DiagramImagePath != null)
{
libraryItem.DiagramImagePath = null;
libraryItem.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
// Null out on all imported copies
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
foreach (var imp in imports)
{
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
imp.ResultingCustomItemTemplateId);
if (copy != null && copy.DiagramImagePath != null)
{
copy.DiagramImagePath = null;
await _unitOfWork.CompleteAsync();
}
}
}
}
/// <inheritdoc />
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
int libraryItemId, int companyId, bool isPositive)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished)
throw new InvalidOperationException("Library entry not found.");
// Companies cannot rate their own formula
if (item.SourceCompanyId == companyId)
throw new InvalidOperationException("You cannot rate your own formula.");
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
bool? myVote;
if (existing != null && existing.IsPositive == isPositive)
{
// Same vote again — toggle off
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
myVote = null;
}
else if (existing != null)
{
// Opposite vote — flip it
existing.IsPositive = isPositive;
existing.RatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
myVote = isPositive;
}
else
{
// New vote
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
{
FormulaLibraryItemId = libraryItemId,
CompanyId = companyId,
IsPositive = isPositive,
RatedAt = DateTime.UtcNow,
});
myVote = isPositive;
}
await _unitOfWork.CompleteAsync();
// Return fresh counts
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => r.FormulaLibraryItemId == libraryItemId);
var list = allRatings.ToList();
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// A template is shareable if it was created fresh (no source library item) or
/// if it was imported but then modified by the company.
/// </summary>
private static bool CanShare(CustomItemTemplate t) =>
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
private static void CopyFromTemplate(
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
{
dest.Name = src.Name;
dest.Description = src.Description;
dest.OutputMode = src.OutputMode;
dest.FieldsJson = src.FieldsJson;
dest.Formula = src.Formula;
dest.DefaultRate = src.DefaultRate;
dest.RateLabel = src.RateLabel;
dest.Notes = src.Notes;
dest.DiagramImagePath = src.DiagramImagePath;
dest.SourceCompanyName = companyName;
dest.Tags = req.Tags?.Trim();
dest.IndustryHint = req.IndustryHint?.Trim();
}
}
@@ -1209,15 +1209,6 @@ Rules:
sb.AppendLine("Page content:"); sb.AppendLine("Page content:");
sb.AppendLine(pageContent); sb.AppendLine(pageContent);
} }
else if (!string.IsNullOrWhiteSpace(fetchUrl))
{
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
// in the path) to infer product identity rather than returning all-null fields.
sb.AppendLine();
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
}
return sb.ToString(); return sb.ToString();
} }
@@ -67,9 +67,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
} }
/// <summary> /// <summary>
/// Opens a DI scope, queries non-Stripe-managed (trial) companies with active or grace-period /// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions,
/// subscriptions, and calls <see cref="ProcessCompanyAsync"/> for each. Each company is saved /// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the
/// individually so a single failure does not prevent other companies from being updated. /// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive.
/// </summary> /// </summary>
private async Task RunAsync(CancellationToken ct) private async Task RunAsync(CancellationToken ct)
{ {
@@ -103,27 +103,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count); _logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
// All companies reaching this point have no StripeSubscriptionId — they are trials.
// Paid subscribers are managed by Stripe and filtered out above.
var effectiveGraceDays = gracePeriodAppliesToTrials ? gracePeriodDays : 0;
foreach (var company in companies) foreach (var company in companies)
{ {
if (ct.IsCancellationRequested) break; if (ct.IsCancellationRequested) break;
try var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
{ var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct); await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process subscription expiry for company {Id} ({Name}). Status change was not persisted.",
company.Id, company.CompanyName);
// Clear EF tracked changes so bad state does not bleed into the next company.
db.ChangeTracker.Clear();
}
} }
await db.SaveChangesAsync(ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -133,15 +121,10 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
/// <summary> /// <summary>
/// Evaluates a single company and performs any required status transitions or reminder sends. /// Evaluates a single company and performs any required status transitions or reminder sends.
/// Transition logic: /// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated.
/// <list type="bullet">
/// <item>Active past end date, grace days = 0 → Expired + deactivated immediately (trials).</item>
/// <item>Active past end date, grace days &gt; 0 → GracePeriod + grace-period email.</item>
/// <item>GracePeriod past grace deadline → Expired + deactivated.</item>
/// </list>
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active. /// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
/// Platform admin is notified asynchronously (fire-and-forget) so that operator action can be taken /// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry
/// without delaying the main processing loop. /// so that operator action can be taken without delaying the main processing loop.
/// </summary> /// </summary>
private async Task ProcessCompanyAsync( private async Task ProcessCompanyAsync(
ApplicationDbContext db, ApplicationDbContext db,
@@ -170,55 +153,35 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
await WriteAuditLogAsync(db, company, await WriteAuditLogAsync(db, company,
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}."); $"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
// Notify platform admin
_ = adminNotification.NotifyCompanyExpiredAsync( _ = adminNotification.NotifyCompanyExpiredAsync(
company.Id, company.CompanyName, company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate); company.PrimaryContactEmail ?? string.Empty, expiredDate);
} }
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate) else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
{ {
if (gracePeriodDays == 0) _logger.LogInformation(
{ "Company {Id} ({Name}) subscription ended. Entering grace period.",
// No grace period configured — expire immediately without going through GracePeriod. company.Id, company.CompanyName);
// Trials always land here since gracePeriodAppliesToTrials defaults to false.
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended with no grace period. Marking Expired and deactivating.",
company.Id, company.CompanyName);
company.SubscriptionStatus = SubscriptionStatus.Expired; company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
company.IsActive = false; company.UpdatedAt = DateTime.UtcNow;
company.UpdatedAt = DateTime.UtcNow; company.UpdatedBy = "System";
company.UpdatedBy = "System";
await WriteAuditLogAsync(db, company, await WriteAuditLogAsync(db, company,
$"Auto-expired: subscription ended {endDate:d} with no grace period."); $"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
_ = adminNotification.NotifyCompanyExpiredAsync( // Send "grace period started" email to company immediately
company.Id, company.CompanyName, await SendEmailIfNotSentAsync(db, emailService, company, today,
company.PrimaryContactEmail ?? string.Empty, endDate); NotificationType.SubscriptionExpiryReminder,
} daysBeforeExpiry: 0,
else gracePeriodDays,
{ ct);
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended. Entering {Days}-day grace period.",
company.Id, company.CompanyName, gracePeriodDays);
company.SubscriptionStatus = SubscriptionStatus.GracePeriod; // Notify platform admin
company.UpdatedAt = DateTime.UtcNow; _ = adminNotification.NotifyCompanyGracePeriodAsync(
company.UpdatedBy = "System"; company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
await WriteAuditLogAsync(db, company,
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
gracePeriodDays,
ct);
_ = adminNotification.NotifyCompanyGracePeriodAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
}
} }
// ── Reminder emails (only while still Active) ──────────────────── // ── Reminder emails (only while still Active) ────────────────────
@@ -33,9 +33,6 @@ public class CompanySettingsController : Controller
private readonly IAuditLogService _auditLog; private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
private readonly IFormulaLibraryService _formulaLibraryService;
public CompanySettingsController( public CompanySettingsController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -48,10 +45,7 @@ public class CompanySettingsController : Controller
IConfiguration configuration, IConfiguration configuration,
IAuditLogService auditLog, IAuditLogService auditLog,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager, SignInManager<ApplicationUser> signInManager)
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService,
IFormulaLibraryService formulaLibraryService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -64,9 +58,6 @@ public class CompanySettingsController : Controller
_auditLog = auditLog; _auditLog = auditLog;
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
_formulaLibraryService = formulaLibraryService;
} }
/// <summary> /// <summary>
@@ -139,20 +130,12 @@ public class CompanySettingsController : Controller
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false; dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
dto.AllowSms = planConfig?.AllowSms ?? false; dto.AllowSms = planConfig?.AllowSms ?? false;
ViewBag.AllowCustomFormulas = AllowCustomFormulas();
dto.SmsEnabled = company.SmsEnabled; dto.SmsEnabled = company.SmsEnabled;
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin; dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
dto.SmsTermsVersion = AppConstants.SmsTermsVersion; dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion); .AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
// Timeclock settings
dto.TimeclockEnabled = company.TimeclockEnabled;
dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay;
dto.TimeclockAutoClockOutHours = company.TimeclockAutoClockOutHours;
var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value);
ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList();
// Flag whether Stripe Connect is configured (non-placeholder client ID) // Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"]; var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId) ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
@@ -751,40 +734,6 @@ public class CompanySettingsController : Controller
} }
} }
// POST: CompanySettings/UpdateTimeclockSettings
/// <summary>
/// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
return Json(new { success = false, message = "Company not found." });
company.TimeclockEnabled = dto.TimeclockEnabled;
company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay;
company.TimeclockAutoClockOutHours = dto.TimeclockAutoClockOutHours;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId);
return Json(new { success = true, message = "Timeclock settings saved." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating timeclock settings");
return Json(new { success = false, message = "An error occurred while saving timeclock settings." });
}
}
/// <summary> /// <summary>
/// Builds a suggested AI profile draft from existing company configuration — company name/location, /// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and /// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
@@ -3013,326 +2962,6 @@ public class CompanySettingsController : Controller
return RedirectToAction(nameof(DeleteAccount)); return RedirectToAction(nameof(DeleteAccount));
} }
} }
// ─── Custom Formula Item Templates ──────────────────────────────────────────
private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplate(int id)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
return Json(new { success = true, template = dto });
}
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplates()
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId);
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
return Json(new { success = true, templates = dtos });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
dto.Formula = normalizedFormula;
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, id = entity.Id });
}
/// <summary>Updates an existing formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
dto.Formula = normalizedFormula;
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
// If this was imported from the library, mark it as modified so the share button appears
if (entity.SourceFormulaLibraryItemId.HasValue)
entity.IsModifiedFromSource = true;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
// ── Community Library: share / unshare / status ───────────────────────
/// <summary>
/// Returns the community library status for a given template: whether it is published,
/// eligible to share, and where it was originally imported from if applicable.
/// </summary>
[HttpGet]
public async Task<IActionResult> FormulaLibraryStatus(int templateId)
{
if (!AllowCustomFormulas()) return Json(new { canShare = false });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
return Json(status);
}
/// <summary>
/// Publishes a company template to the community library (or re-publishes after unshare).
/// Only templates that are original creations or modified imports may be shared.
/// </summary>
[HttpPost]
public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
try
{
var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
return Json(new { success = true, libraryItemId });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
[HttpPost]
public async Task<IActionResult> UnshareFormula(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
return Json(new { success = true });
}
/// <summary>
/// Uploads a diagram image for a template to blob storage container
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
/// Returns the blob path for storage on the entity.
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
if (diagramFile.Length > 10 * 1024 * 1024)
return Json(new { success = false, message = "Image must be under 10 MB." });
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
using var stream = diagramFile.OpenReadStream();
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
if (!ok)
return Json(new { success = false, message = err });
entity.DiagramImagePath = blobPath;
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true, diagramImagePath = blobPath });
}
/// <summary>
/// Serves a template diagram image from blob storage. The path is tenant-scoped
/// so cross-company access is prevented by checking CompanyId on the template.
/// </summary>
[HttpGet]
public async Task<IActionResult> TemplateDiagram(int templateId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
return NotFound();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Evaluates a NCalc formula with the supplied variable values, automatically injecting
/// three read-only shop-rate variables sourced from the company's operating costs:
/// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
/// User-supplied variables take precedence so the test panel can override them.
/// </summary>
[HttpPost]
public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
// Inject shop-rate system variables; user-supplied values win if the same key appears in both.
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId != null)
{
var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
if (costs != null)
req = InjectShopRateVariables(req, costs);
}
var result = _formulaAiService.EvaluateFormula(req);
return Json(result);
}
/// <summary>
/// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
/// from <paramref name="costs"/> into the request's variable map without overwriting any key
/// the caller already set (so the test panel can still override these values explicitly).
/// </summary>
private static EvaluateFormulaRequest InjectShopRateVariables(
EvaluateFormulaRequest req, CompanyOperatingCosts costs)
{
var vars = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();
void Inject(string key, decimal value)
{
if (!vars.ContainsKey(key))
vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
}
Inject("standard_labor_rate", costs.StandardLaborRate);
Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
Inject("markup_pct", costs.GeneralMarkupPercentage);
return new EvaluateFormulaRequest
{
Formula = req.Formula,
VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
};
}
/// <summary>
/// Calls Claude to generate a formula template from a natural-language description
/// and an optional diagram image uploaded in the same multipart form.
/// </summary>
[HttpPost]
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
{
if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
if (string.IsNullOrWhiteSpace(description))
return Json(new { success = false, error = "Description is required." });
byte[]? imageBytes = null;
string? imageContentType = null;
if (diagramImage is { Length: > 0 })
{
using var ms = new MemoryStream();
await diagramImage.CopyToAsync(ms);
imageBytes = ms.ToArray();
imageContentType = diagramImage.ContentType;
}
var result = await _formulaAiService.GenerateFormulaAsync(
new GenerateFormulaFromAiRequest { Description = description },
imageBytes,
imageContentType);
return Json(result);
}
/// <summary>
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
/// must start with a letter, contain only letters/digits/underscores, and not use the
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
/// Returns an error message string on failure, or null if all names are valid.
/// </summary>
private static string? ValidateTemplateFields(string? fieldsJson)
{
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
List<System.Text.Json.JsonElement>? fields;
try
{
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
}
catch { return "Invalid fields JSON."; }
if (fields == null) return null;
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name))
return "All fields must have a variable name.";
if (name == "rate")
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
if (!nameRegex.IsMatch(name))
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
if (!seen.Add(name))
return $"Duplicate field name \"{name}\".";
}
return null;
}
} }
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body); public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
@@ -293,48 +293,6 @@ public class DataPurgeController : Controller
break; break;
case "Jobs": case "Jobs":
// Collect IDs first so all FK cleanup targets the exact same set of jobs.
var purgingJobIds = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff)
.Select(e => e.Id)
.ToListAsync();
if (purgingJobIds.Count > 0)
{
// Non-nullable FK children must be deleted before the parent row can go.
// ReworkRecord.JobId (Restrict), PowderUsageLog.JobId (NoAction),
// OvenBatchItem.JobId (NoAction) — cannot be nulled, so rows are removed.
await _db.ReworkRecords.IgnoreQueryFilters()
.Where(r => purgingJobIds.Contains(r.JobId))
.ExecuteDeleteAsync();
await _db.PowderUsageLogs.IgnoreQueryFilters()
.Where(l => purgingJobIds.Contains(l.JobId))
.ExecuteDeleteAsync();
await _db.OvenBatchItems.IgnoreQueryFilters()
.Where(i => purgingJobIds.Contains(i.JobId))
.ExecuteDeleteAsync();
// Nullable FKs with NO ACTION / RESTRICT — null them out so the DELETE
// does not violate the constraint. KioskSession and NotificationLog are
// excluded here because their FKs use SET NULL and the DB handles them.
await _db.Invoices.IgnoreQueryFilters()
.Where(i => i.JobId.HasValue && purgingJobIds.Contains(i.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(i => i.JobId, (int?)null));
await _db.Deposits.IgnoreQueryFilters()
.Where(d => d.JobId.HasValue && purgingJobIds.Contains(d.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(d => d.JobId, (int?)null));
await _db.Appointments.IgnoreQueryFilters()
.Where(a => a.JobId.HasValue && purgingJobIds.Contains(a.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.JobId, (int?)null));
await _db.BillLineItems.IgnoreQueryFilters()
.Where(b => b.JobId.HasValue && purgingJobIds.Contains(b.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(b => b.JobId, (int?)null));
await _db.Expenses.IgnoreQueryFilters()
.Where(e => e.JobId.HasValue && purgingJobIds.Contains(e.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.JobId, (int?)null));
await _db.InventoryTransactions.IgnoreQueryFilters()
.Where(t => t.JobId.HasValue && purgingJobIds.Contains(t.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.JobId, (int?)null));
}
count = await _db.Jobs.IgnoreQueryFilters() count = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync(); .Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break; break;
@@ -1,142 +0,0 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Community formula library — browse published formulas from all companies and import
/// them into the current company's local template list.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class FormulaLibraryController : Controller
{
private readonly IFormulaLibraryService _libraryService;
private readonly ITenantContext _tenantContext;
private readonly IMapper _mapper;
private readonly IAzureBlobStorageService _blobStorage;
public FormulaLibraryController(
IFormulaLibraryService libraryService,
ITenantContext tenantContext,
IMapper mapper,
IAzureBlobStorageService blobStorage)
{
_libraryService = libraryService;
_tenantContext = tenantContext;
_mapper = mapper;
_blobStorage = blobStorage;
}
/// <summary>Browse the community library with optional search and filter params.</summary>
// GET: /FormulaLibrary
public async Task<IActionResult> Index(
string? search = null,
string? outputMode = null,
string? industryHint = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction("Index", "Home");
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
ViewBag.Search = search;
ViewBag.OutputMode = outputMode;
ViewBag.IndustryHint = industryHint;
ViewBag.TotalCount = items.Count();
return View(items);
}
/// <summary>Returns full detail JSON for the import preview modal.</summary>
// GET: /FormulaLibrary/Detail/5
[HttpGet]
public async Task<IActionResult> Detail(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { error = "No company context." });
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
if (detail == null) return NotFound();
return Json(detail);
}
/// <summary>
/// Serves a formula diagram image by blob storage path. Used for library cards where the
/// diagram belongs to another company's template blob container.
/// </summary>
// GET: /FormulaLibrary/Diagram?path=...
[HttpGet]
public async Task<IActionResult> Diagram(string path)
{
if (string.IsNullOrWhiteSpace(path)) return NotFound();
// Sanitize: path must not escape the blob container
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
return BadRequest();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Records or toggles a thumbs-up/down vote for the current company.
/// Returns updated counts so the UI can update without a page reload.
/// Companies cannot rate their own formulas; own-formula cards have no rating buttons.
/// </summary>
// POST: /FormulaLibrary/Rate
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Rate([FromBody] RateFormulaRequest request)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company context." });
try
{
var (up, down, myVote) = await _libraryService.RateAsync(
request.LibraryItemId, companyId.Value, request.IsPositive);
return Json(new { success = true, thumbsUp = up, thumbsDown = down, myVote });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Imports a library entry as a new local template for the current company.</summary>
// POST: /FormulaLibrary/Import
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Import(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "No company context." });
try
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
return Json(new { success = true, templateId });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
}
/// <summary>Body for the Rate endpoint.</summary>
public class RateFormulaRequest
{
public int LibraryItemId { get; set; }
/// <summary>True = thumbs up, false = thumbs down.</summary>
public bool IsPositive { get; set; }
}
@@ -125,23 +125,5 @@ namespace PowderCoating.Web.Controllers
{ {
return View(); return View();
} }
/// <summary>
/// Serves the Custom Formula Item Templates help article explaining how to create NCalc formula
/// templates, use the AI generator, and add formula items to quotes and jobs.
/// </summary>
public IActionResult CustomFormulaTemplates()
{
return View();
}
/// <summary>
/// Serves the Timeclock help article explaining clock in/out, multi-segment days, the kiosk tablet
/// mode with PIN authentication, manager edit/delete tools, and the Attendance report.
/// </summary>
public IActionResult Timeclock()
{
return View();
}
} }
} }
@@ -56,16 +56,14 @@ public class InventoryController : Controller
/// <summary> /// <summary>
/// Displays the paginated inventory list with optional keyword search, category filter, /// Displays the paginated inventory list with optional keyword search, category filter,
/// color family filter, and a low-stock quick-filter. When lowStockOnly is active the /// and a low-stock quick-filter. When lowStockOnly is active the default sort switches
/// default sort switches to QuantityOnHand ascending so the most depleted items surface /// to QuantityOnHand ascending so the most depleted items surface immediately. Stats
/// immediately. Stats (total value, active count, low-stock count) are computed directly /// (total value, active count, low-stock count) are computed directly on the DbSet
/// on the DbSet using aggregate SQL to avoid loading all rows into memory. /// using aggregate SQL to avoid loading all rows into memory.
/// </summary> /// </summary>
public async Task<IActionResult> Index( public async Task<IActionResult> Index(
string? searchTerm, string? searchTerm,
string? category, string? category,
string? location,
string? colorFamily,
string? sortColumn, string? sortColumn,
string sortDirection = "asc", string sortDirection = "asc",
bool lowStockOnly = false, bool lowStockOnly = false,
@@ -89,34 +87,49 @@ public class InventoryController : Controller
}; };
gridRequest.Validate(); gridRequest.Validate();
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm); // Build search and category filter
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
var colorFam = colorFamily ?? "";
// Single composable predicate — EF Core evaluates the captured booleans as constants
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null; System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
{ {
filter = i => var search = searchTerm.ToLower();
(!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) && filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
(!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search) && (i.SKU.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search)) || i.Name.ToLower().Contains(search)
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search)) || (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) && || (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
(!hasCategory || i.Category.ToLower() == cat.ToLower()) && }
(!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) && else if (lowStockOnly)
(!hasColorFamily || (i.ColorFamilies != null && ( {
i.ColorFamilies == colorFam || filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
i.ColorFamilies.StartsWith(colorFam + ",") || }
i.ColorFamilies.EndsWith("," + colorFam) || else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
i.ColorFamilies.Contains("," + colorFam + ",")))); {
// Both search and category filter
var search = searchTerm.ToLower();
var cat = category;
filter = i => (i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
&& i.Category.ToLower() == cat.ToLower();
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = i => i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
}
else if (!string.IsNullOrWhiteSpace(category))
{
// Category filter only
var cat = category;
filter = i => i.Category.ToLower() == cat.ToLower();
} }
// Build orderBy function // Build orderBy function
@@ -146,30 +159,19 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount); var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and dropdown option lists in memory // Load all items once to compute sidebar stats and category list in memory
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList(); ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
ViewBag.ColorFamilies = allItems
.Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
.SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(f => f.Trim())
.Where(f => f.Length > 0)
.Distinct()
.OrderBy(f => f)
.ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m; ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Set ViewBag for sorting and filters // Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm; ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category; ViewBag.Category = category;
ViewBag.Location = location;
ViewBag.ColorFamily = colorFamily;
ViewBag.LowStockOnly = lowStockOnly; ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection; ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult); return View(pagedResult);
@@ -182,26 +184,6 @@ public class InventoryController : Controller
} }
} }
/// <summary>
/// Returns a print-optimised list of all active inventory items in a given bin/location.
/// Renders without the site chrome (no layout) so the browser print dialog produces a
/// clean page. Items are sorted by name within the bin.
/// </summary>
public async Task<IActionResult> PrintBin(string location)
{
if (string.IsNullOrWhiteSpace(location))
return RedirectToAction(nameof(Index));
var loc = location.Trim();
var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc;
ViewBag.PrintedAt = DateTime.Now;
return View(dtos);
}
/// <summary> /// <summary>
/// Renders the inventory item detail page. The primary vendor name is looked up /// Renders the inventory item detail page. The primary vendor name is looked up
/// separately because the repository does not eager-load Vendor by default, avoiding /// separately because the repository does not eager-load Vendor by default, avoiding
@@ -915,31 +897,10 @@ public class InventoryController : Controller
if (!string.IsNullOrWhiteSpace(qrUrl)) if (!string.IsNullOrWhiteSpace(qrUrl))
{ {
// QR path: try to extract product identity from the URL using manufacturer patterns. // QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
// Prismatic Powders URLs embed both the SKU and color slug in the path aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full if (aiResult.Success && aiResult.SpecPageUrl == null)
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of aiResult.SpecPageUrl = qrUrl;
// relying solely on a page fetch that may fail in production.
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
.Where(p => p.IsActive).ToList();
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
if (!string.IsNullOrWhiteSpace(urlMfr))
{
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
// The scanned QR URL is always the authoritative product page link — it came
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
// whatever LookupAsync returned (which may be a scheme-less path from the template).
if (aiResult.Success)
aiResult.SpecPageUrl = qrUrl;
}
else
{
// No pattern match — fall back to URL-based lookup
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
} }
else if (!string.IsNullOrWhiteSpace(imageBase64)) else if (!string.IsNullOrWhiteSpace(imageBase64))
{ {
@@ -1222,16 +1183,12 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign. // Find the default coating category to assign
// Prefer the canonical "POWDER" category (CategoryCode == "POWDER") so catalog-sourced
// items always land in the right bucket regardless of how many IsCoating categories exist.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating var coatingCategory = categories
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase)) .Where(c => c.IsActive && c.IsCoating)
?? categories .OrderBy(c => c.DisplayOrder)
.Where(c => c.IsActive && c.IsCoating) .FirstOrDefault();
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
if (coatingCategory == null) if (coatingCategory == null)
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." }); return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
@@ -1315,72 +1272,6 @@ public class InventoryController : Controller
return transferEfficiency ?? DefaultTransferEfficiency; return transferEfficiency ?? DefaultTransferEfficiency;
} }
/// <summary>
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
/// when no active pattern matches the URL domain.
/// </summary>
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
{
Uri? parsed;
try { parsed = new Uri(url); } catch { return (null, null, null); }
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
{
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
var template = pattern.ProductUrlTemplate!;
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
// Find the first placeholder to split off the static prefix
var firstIdx = placeholderKeys
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
.Where(i => i >= 0)
.DefaultIfEmpty(-1)
.Min();
if (firstIdx < 0) continue;
var staticPrefix = template[..firstIdx].TrimEnd('/');
var fullUrl = url.TrimEnd('/');
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
bool matched = true;
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
{
var seg = templateSegments[i];
if (seg.StartsWith("{") && seg.EndsWith("}"))
extracted[seg[1..^1]] = urlSegments[i];
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
{ matched = false; break; }
}
if (!matched) continue;
extracted.TryGetValue("partNumber", out var partNumber);
var slug = extracted.TryGetValue("slug", out var s) ? s
: extracted.TryGetValue("colorName", out var cn) ? cn
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
// Convert URL slug to display name: "fire-red" → "Fire Red"
string? colorName = slug == null ? null
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
return (pattern.ManufacturerName, colorName, partNumber);
}
return (null, null, null);
}
/// <summary> /// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to /// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted /// inventory item names on create and edit so the list view is consistently formatted
@@ -1480,20 +1371,8 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories)) var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
.OrderBy(v => v.CompanyName).ToList(); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
var categoryVendorMap = new Dictionary<string, List<int>>();
foreach (var v in vendors)
foreach (var cat in v.Categories)
{
var key = cat.Id.ToString();
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
categoryVendorMap[key].Add(v.Id);
}
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
// Load categories from lookup table // Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
@@ -1620,12 +1499,11 @@ public class InventoryController : Controller
/// Renders a print-optimised label for the inventory item containing the QR code, /// Renders a print-optimised label for the inventory item containing the QR code,
/// item name, SKU, and colour. Designed to be printed directly from the browser. /// item name, SKU, and colour. Designed to be printed directly from the browser.
/// </summary> /// </summary>
public async Task<IActionResult> Label(int? id, bool embed = false) public async Task<IActionResult> Label(int? id)
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value); var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
if (item == null) return NotFound(); if (item == null) return NotFound();
ViewBag.IsEmbed = embed;
return View(_mapper.Map<InventoryItemDto>(item)); return View(_mapper.Map<InventoryItemDto>(item));
} }
@@ -489,12 +489,9 @@ public class JobsController : Controller
manualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem || ji.IsSalesItem ? ji.UnitPrice : (decimal?)null), manualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem || ji.IsSalesItem ? ji.UnitPrice : (decimal?)null),
powderCostOverride = ji.PowderCostOverride, powderCostOverride = ji.PowderCostOverride,
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem), isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
isLaborItem = ji.IsLaborItem, isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem, isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem, isAiItem = ji.IsAiItem,
isCustomFormulaItem = ji.IsCustomFormulaItem,
customItemTemplateId = ji.CustomItemTemplateId,
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
sku = ji.Sku, sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting, requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking, requiresMasking = ji.RequiresMasking,
@@ -1176,26 +1173,6 @@ public class JobsController : Controller
} }
} }
// Option B: auto-add Custom Powder Order item on first save if not already present
var allCreateItems = dto.JobItems.ToList();
if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allCreateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allCreateItems.Add(powderDto);
}
}
// Recalculate total from wizard items // Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId); var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null; decimal? createOvenRate = null;
@@ -1206,7 +1183,7 @@ public class JobsController : Controller
createOvenRate = createOven.CostPerHour; createOvenRate = createOven.CostPerHour;
} }
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
allCreateItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
@@ -1302,12 +1279,9 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null), ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride, PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -1420,26 +1394,6 @@ public class JobsController : Controller
} }
} }
// Option B: auto-add Custom Powder Order item on first save if not already present
var allEditItems = dto.JobItems.ToList();
if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allEditItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allEditItems.Add(powderDto);
}
}
// Now load and update the job itself // Now load and update the job itself
var job = await _unitOfWork.Jobs.GetByIdAsync(id); var job = await _unitOfWork.Jobs.GetByIdAsync(id);
if (job == null) if (job == null)
@@ -1693,7 +1647,7 @@ public class JobsController : Controller
} }
// Recalculate FinalPrice from wizard items // Recalculate FinalPrice from wizard items
if (allEditItems.Any()) if (dto.JobItems.Any())
{ {
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId); var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null; decimal? editOvenRate = null;
@@ -1704,7 +1658,7 @@ public class JobsController : Controller
editOvenRate = editOven.CostPerHour; editOvenRate = editOven.CostPerHour;
} }
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
allEditItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
@@ -1898,33 +1852,6 @@ public class JobsController : Controller
{ {
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
await PopulateDropdowns(); await PopulateDropdowns();
await PopulatePrepServicesAsync(companyId); await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId); var costs = await _pricingService.GetOperatingCostsAsync(companyId);
@@ -2480,28 +2407,6 @@ public class JobsController : Controller
}); });
} }
// When a rework job reaches a terminal status, close out the linked ReworkRecord
// on the original job so the shop doesn't have to do it manually.
// Cancelled → WrittenOff; any other terminal → Resolved.
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
{
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
foreach (var rr in linkedRecords)
{
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
continue;
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
? ReworkStatus.WrittenOff
: ReworkStatus.Resolved;
rr.ResolvedDate ??= DateTime.UtcNow;
rr.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
}
if (linkedRecords.Any())
await _unitOfWork.SaveChangesAsync();
}
// Notify customer on status change (only if user opted in) // Notify customer on status change (only if user opted in)
if (request.SendEmail && newStatus != null) if (request.SendEmail && newStatus != null)
{ {
@@ -3054,12 +2959,9 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null), ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride, PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -3175,26 +3077,6 @@ public class JobsController : Controller
} }
} }
// Option B: auto-add Custom Powder Order item on first save if not already present
var allUpdateItems = model.JobItems.ToList();
if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allUpdateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allUpdateItems.Add(powderDto);
}
}
// Calculate full total (overhead, margins, tax) matching what Details shows // Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null; decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue) if (job.OvenCostId.HasValue)
@@ -3205,7 +3087,7 @@ public class JobsController : Controller
} }
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
allUpdateItems, currentUser.CompanyId, job.CustomerId, model.JobItems, currentUser.CompanyId, job.CustomerId,
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m), await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob, job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes); ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
@@ -3268,13 +3150,10 @@ public class JobsController : Controller
SurfaceAreaSqFt = ji.SurfaceAreaSqFt, SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
EstimatedMinutes = ji.EstimatedMinutes, EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem, IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem, IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem, IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null), ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost, IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
@@ -3408,21 +3287,6 @@ public class JobsController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas2)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
} }
/// <summary> /// <summary>
@@ -3458,66 +3322,6 @@ public class JobsController : Controller
return companyDefaultRate; return companyDefaultRate;
} }
/// <summary>
/// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the
/// submitted items. Returns null when no qualifying coats are present.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, PowderToOrder &gt; 0, PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder &gt; 0
/// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost)
/// </summary>
private async Task<CreateQuoteItemDto?> BuildCustomPowderOrderDto(IEnumerable<CreateQuoteItemDto> itemDtos)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var dto in itemDtos)
{
if (dto.Coats == null) continue;
foreach (var coat in dto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder: no inventory link, user entered cost per lb manually
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Incoming powder: catalog-selected; PowderCostPerLb was cleared after incoming
// inventory item was created, so cost comes from inventoryItem.UnitCost
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
colorNames.Add(colorName);
}
}
}
}
if (totalCost <= 0) return null;
var uniqueColors = colorNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var description = uniqueColors.Any()
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
: "Custom Powder Order";
return new CreateQuoteItemDto
{
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost
};
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) => private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto new QuotePricingBreakdownDto
{ {
@@ -3724,13 +3528,10 @@ public class JobsController : Controller
} }
/// <summary> /// <summary>
/// Records a rework event against a job. Optionally creates a linked rework job so the /// Records a rework event against a job item (e.g. defect found during QC).
/// repair can flow through the full shop lifecycle. When creating a rework job: /// Automatically creates a new linked rework Job so the repair work can be tracked
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1) /// through the same job lifecycle. The rework job inherits the original job's customer,
/// - Only items selected by the user are copied (partial rework support) /// oven, and items so the shop has a complete specification to work from.
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
/// - Job starts at the first non-Pending status in the company's workflow
/// </summary> /// </summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto) public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
@@ -3739,207 +3540,95 @@ public class JobsController : Controller
if (job == null) return NotFound(); if (job == null) return NotFound();
var companyId = job.CompanyId; var companyId = job.CompanyId;
Job? reworkJob = null;
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue) // Generate rework job number
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = pendingStatus != null ? new Job
{ {
var typeLabel = dto.ReworkType switch JobNumber = $"{prefix}{(maxNum + 1):D4}",
{ CustomerId = job.CustomerId,
ReworkType.InternalDefect => "Internal Defect", Description = $"REWORK: {job.Description}",
ReworkType.CustomerWarranty => "Customer Warranty", JobStatusId = pendingStatus.Id,
ReworkType.CustomerDamage => "Customer Damage", JobPriorityId = normalPriority.Id,
_ => dto.ReworkType.ToString() IsReworkJob = true,
}; OriginalJobId = job.Id,
var reasonLabel = dto.Reason switch SpecialInstructions = $"Rework of {job.JobNumber}.",
{ CompanyId = companyId,
ReworkReason.AdhesionFailure => "Adhesion Failure", CreatedAt = DateTime.UtcNow
ReworkReason.Contamination => "Contamination", } : null;
ReworkReason.ColorMismatch => "Color Mismatch",
ReworkReason.RunsSags => "Runs / Sags",
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
ReworkReason.OvenIssue => "Oven Issue",
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
ReworkReason.HandlingDamage => "Handling Damage",
_ => "Other"
};
var pricingLabel = dto.ReworkPricingType.Value switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
var currentUserId = _userManager.GetUserId(User); if (reworkJob != null)
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId); {
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Copy items: specific item if flagged, otherwise all items
var itemsToCopy = dto.JobItemId.HasValue
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
: job.JobItems.ToList();
foreach (var item in itemsToCopy)
{
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.CompleteAsync();
} }
var record = new ReworkRecord var record = new ReworkRecord
{ {
JobId = dto.JobId, JobId = dto.JobId,
JobItemId = dto.JobItemId, JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType, ReworkType = dto.ReworkType,
Reason = dto.Reason, Reason = dto.Reason,
DefectDescription = dto.DefectDescription, DefectDescription = dto.DefectDescription,
DiscoveredBy = dto.DiscoveredBy, DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate, DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName, ReportedByName = dto.ReportedByName,
EstimatedReworkCost = dto.EstimatedReworkCost, EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer, IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes, BillingNotes = dto.BillingNotes,
ReworkPricingType = dto.ReworkPricingType, ReworkJobId = reworkJob?.Id,
ReworkJobId = reworkJob?.Id, Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open, CompanyId = companyId,
CompanyId = companyId, CreatedAt = DateTime.UtcNow
CreatedAt = DateTime.UtcNow
}; };
await _unitOfWork.ReworkRecords.AddAsync(record); await _unitOfWork.ReworkRecords.AddAsync(record);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Reload with navigation for response
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob); var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
return Json(_mapper.Map<ReworkRecordDto>(saved.First())); return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
} }
/// <summary>
/// Creates a linked rework Job from an existing rework record that was saved without one.
/// Uses sub-number format and applies the specified pricing attribution.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId;
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
var pricingLabel = pricingType switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
var currentUserId = _userManager.GetUserId(User);
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.ReworkPricingType = pricingType;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
/// <summary>
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
/// copies the specified items (with coats and prep services), applies pricing attribution,
/// sets descriptive job description from the rework record data, and auto-records intake
/// (parts are already on hand when rework is logged).
/// Called by both AddReworkRecord and CreateReworkJob.
/// </summary>
private async Task<Job> BuildReworkJobAsync(
Job originalJob,
List<int> itemIds,
ReworkPricingType pricingType,
int companyId,
string reworkDescription,
string? checkedByUserId)
{
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
// First non-Pending status by workflow order
var firstActiveStatus = statuses
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
.OrderBy(s => s.DisplayOrder)
.First();
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job
{
JobNumber = reworkNumber,
CustomerId = originalJob.CustomerId,
Description = reworkDescription,
JobStatusId = firstActiveStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
// Auto-intake: parts are already on hand when rework is logged
IntakeDate = DateTime.UtcNow,
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
IntakeCheckedByUserId = checkedByUserId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
var createdAtUtc = DateTime.UtcNow;
foreach (var item in itemsToCopy)
{
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
// Shop-fault rework jobs are done at no charge
if (pricingType == ReworkPricingType.ShopFault)
{
newItem.UnitPrice = 0;
newItem.ManualUnitPrice = 0;
newItem.TotalPrice = 0;
}
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemCoats.AddAsync(coat);
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
}
// Set intake part count now that items are known
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
// Write a pricing snapshot so the Details page and inline edit both work correctly
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
? 0m
: itemsToCopy.Sum(i => i.TotalPrice);
reworkJob.FinalPrice = itemsSubtotal;
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = itemsSubtotal,
SubtotalAfterDiscount = itemsSubtotal,
Total = itemsSubtotal
});
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
await _unitOfWork.CompleteAsync();
return reworkJob;
}
/// <summary> /// <summary>
/// Updates a rework record's status, resolution notes, cost, and billability. /// Updates a rework record's status, resolution notes, cost, and billability.
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set). /// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
@@ -3991,6 +3680,66 @@ public class JobsController : Controller
return Json(new { success = true }); return Json(new { success = true });
} }
/// <summary>
/// Creates a new rework Job from an existing rework record and links them.
/// The rework job is a lightweight clone of the original job — same customer, description, and
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = reworkRecord.Job;
var companyId = originalJob.CompanyId;
// Load status lookups to find Pending status
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Generate job number
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = originalJob.CustomerId,
Description = $"REWORK: {originalJob.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Link rework record to new job
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ────────────────────────────────────────── // ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary> /// <summary>
@@ -4562,13 +4311,7 @@ public class LogMaterialRequest
public string TransactionType { get; set; } = "JobUsage"; public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; } public string? Notes { get; set; }
} }
public class CreateReworkJobRequest public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
{
public int ReworkRecordId { get; set; }
public List<int>? ItemIds { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public string? Notes { get; set; }
}
public class UpdateWorkerAssignmentRequest public class UpdateWorkerAssignmentRequest
{ {
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
// Calculate next scheduled maintenance // Calculate next scheduled maintenance
if (equipment.RecommendedMaintenanceIntervalDays > 0) if (equipment.RecommendedMaintenanceIntervalDays > 0)
{ {
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value); equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
} }
equipment.UpdatedAt = DateTime.UtcNow; equipment.UpdatedAt = DateTime.UtcNow;
@@ -65,7 +65,6 @@ public class PlatformSubscriptionController : Controller
AllowAiInventoryAssist = c.AllowAiInventoryAssist, AllowAiInventoryAssist = c.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck, AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
AllowSms = c.AllowSms, AllowSms = c.AllowSms,
AllowCustomFormulas = c.AllowCustomFormulas,
IsActive = c.IsActive, IsActive = c.IsActive,
SortOrder = c.SortOrder SortOrder = c.SortOrder
}).ToList(); }).ToList();
@@ -107,7 +106,6 @@ public class PlatformSubscriptionController : Controller
AllowAiInventoryAssist = config.AllowAiInventoryAssist, AllowAiInventoryAssist = config.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck, AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
AllowSms = config.AllowSms, AllowSms = config.AllowSms,
AllowCustomFormulas = config.AllowCustomFormulas,
IsActive = config.IsActive IsActive = config.IsActive
}; };
@@ -154,7 +152,6 @@ public class PlatformSubscriptionController : Controller
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist; config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck; config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
config.AllowSms = dto.AllowSms; config.AllowSms = dto.AllowSms;
config.AllowCustomFormulas = dto.AllowCustomFormulas;
config.IsActive = dto.IsActive; config.IsActive = dto.IsActive;
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config); await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
@@ -28,7 +28,6 @@ public class QuoteApprovalController : Controller
private readonly ILogger<QuoteApprovalController> _logger; private readonly ILogger<QuoteApprovalController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IHubContext<NotificationHub> _hub; private readonly IHubContext<NotificationHub> _hub;
private readonly IQuotePricingAssemblyService _assemblyService;
public QuoteApprovalController( public QuoteApprovalController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -37,8 +36,7 @@ public class QuoteApprovalController : Controller
IStripeConnectService stripeConnect, IStripeConnectService stripeConnect,
ILogger<QuoteApprovalController> logger, ILogger<QuoteApprovalController> logger,
IConfiguration configuration, IConfiguration configuration,
IHubContext<NotificationHub> hub, IHubContext<NotificationHub> hub)
IQuotePricingAssemblyService assemblyService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_notifications = notifications; _notifications = notifications;
@@ -47,7 +45,6 @@ public class QuoteApprovalController : Controller
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_hub = hub; _hub = hub;
_assemblyService = assemblyService;
} }
/// <summary> /// <summary>
@@ -180,16 +177,6 @@ public class QuoteApprovalController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Create incoming inventory records for catalog-sourced coats deferred from quote-save time.
try
{
await _assemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(quote.Id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", quote.Id);
}
var approveEntry = new QuoteChangeHistory var approveEntry = new QuoteChangeHistory
{ {
QuoteId = quote.Id, QuoteId = quote.Id,
@@ -386,29 +386,27 @@ public class QuotesController : Controller
// never displays "× 0 min" when the oven was priced against DefaultOvenCycleMinutes. // never displays "× 0 min" when the oven was priced against DefaultOvenCycleMinutes.
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{ {
MaterialCosts = quote.MaterialCosts, MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts, LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts, EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal, ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost, OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches, OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0, OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost, ShopSuppliesAmount = quote.ShopSuppliesAmount,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour, ShopSuppliesPercent = quote.ShopSuppliesPercent,
ShopSuppliesAmount = quote.ShopSuppliesAmount, OverheadCosts = quote.OverheadAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent, OverheadPercent = quote.OverheadPercent,
OverheadCosts = quote.OverheadAmount, ProfitMargin = quote.ProfitMargin,
OverheadPercent = quote.OverheadPercent, ProfitPercent = quote.ProfitPercent,
ProfitMargin = quote.ProfitMargin, SubtotalBeforeDiscount = quote.SubTotal,
ProfitPercent = quote.ProfitPercent, DiscountAmount = quote.DiscountAmount,
SubtotalBeforeDiscount = quote.SubTotal, DiscountPercent = quote.DiscountPercent,
DiscountAmount = quote.DiscountAmount, SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent, RushFee = quote.RushFee,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount, TaxPercent = quote.TaxPercent,
RushFee = quote.RushFee, TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent, Total = quote.Total
TaxAmount = quote.TaxAmount,
Total = quote.Total
}; };
// Load change history // Load change history
@@ -572,29 +570,27 @@ public class QuotesController : Controller
// Populate pricing breakdown from stored snapshot values — never recalculate on load // Populate pricing breakdown from stored snapshot values — never recalculate on load
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{ {
MaterialCosts = quote.MaterialCosts, MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts, LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts, EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal, ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost, OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches, OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0, OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost, ShopSuppliesAmount = quote.ShopSuppliesAmount,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour, ShopSuppliesPercent = quote.ShopSuppliesPercent,
ShopSuppliesAmount = quote.ShopSuppliesAmount, OverheadCosts = quote.OverheadAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent, OverheadPercent = quote.OverheadPercent,
OverheadCosts = quote.OverheadAmount, ProfitMargin = quote.ProfitMargin,
OverheadPercent = quote.OverheadPercent, ProfitPercent = quote.ProfitPercent,
ProfitMargin = quote.ProfitMargin, SubtotalBeforeDiscount = quote.SubTotal,
ProfitPercent = quote.ProfitPercent, DiscountAmount = quote.DiscountAmount,
SubtotalBeforeDiscount = quote.SubTotal, DiscountPercent = quote.DiscountPercent,
DiscountAmount = quote.DiscountAmount, SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent, RushFee = quote.RushFee,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount, TaxPercent = quote.TaxPercent,
RushFee = quote.RushFee, TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent, Total = quote.Total
TaxAmount = quote.TaxAmount,
Total = quote.Total
}; };
if (currentUser?.CompanyId == null) if (currentUser?.CompanyId == null)
{ {
@@ -1320,7 +1316,6 @@ public class QuotesController : Controller
Terms = quote.Terms, Terms = quote.Terms,
Notes = quote.Notes, Notes = quote.Notes,
TaxPercent = quote.TaxPercent, TaxPercent = quote.TaxPercent,
Total = quote.Total,
DiscountType = quote.DiscountType, DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue, DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason, DiscountReason = quote.DiscountReason,
@@ -1343,26 +1338,8 @@ public class QuotesController : Controller
// Set calculated pricing — snapshot at save time; never recalculate on load // Set calculated pricing — snapshot at save time; never recalculate on load
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult); _quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// All change history records are accumulated here, then saved in bulk below
var changeHistories = new List<QuoteChangeHistory>();
// Log a total-change entry now that the new Total is known
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Total",
OldValue = oldValues.Total.ToString("C"),
NewValue = quote.Total.ToString("C"),
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
CompanyId = currentUser.CompanyId
});
}
// Track changes // Track changes
var changeHistories = new List<QuoteChangeHistory>();
_logger.LogInformation("=== CHANGE TRACKING DEBUG ==="); _logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId); _logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
@@ -2336,17 +2313,6 @@ public class QuotesController : Controller
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id); _logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
// Create incoming inventory records for any catalog-sourced coats that were deferred
// from quote-save time. One record per unique powder catalog item, de-duplicated.
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", id);
}
// Notify customer that quote is approved (only if user opted in) // Notify customer that quote is approved (only if user opted in)
if (sendEmail) if (sendEmail)
{ {
@@ -2463,33 +2429,6 @@ public class QuotesController : Controller
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0); var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
// Customers // Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers ViewBag.Customers = customers
@@ -2832,20 +2771,6 @@ public class QuotesController : Controller
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
// When transitioning to Approved: create incoming inventory records for catalog-sourced
// coats that were deferred from quote-save time (one record per unique powder, deduplicated).
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
{
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(request.QuoteId, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", request.QuoteId);
}
}
// Auto-create job when quote is approved — guard against double-conversion // Auto-create job when quote is approved — guard against double-conversion
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check) // (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
@@ -3193,22 +3118,6 @@ public class QuotesController : Controller
} }
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
// Log send event so the history timeline shows when the quote was emailed
var sentHistoryEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser!.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Sent",
OldValue = null,
NewValue = recipientEmail,
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride); await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
@@ -1722,129 +1722,6 @@ public class ReportsController : Controller
return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg }); return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
} }
/// <summary>
/// Job Profitability report — compares each job's final price and collected amount against
/// actual labor cost (hours × StandardLaborRate) and estimated powder cost (PowderToOrder ×
/// PowderCostPerLb, or ActualPowderUsedLbs when recorded). Only jobs with at least one
/// JobTimeEntry contribute meaningful labor cost figures; the report flags rows without time
/// tracking so the user knows which margin estimates are incomplete.
/// </summary>
public async Task<IActionResult> JobProfitability(int months = 6, bool timeTrackedOnly = false)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var cutoff = DateTime.UtcNow.AddMonths(-months);
// ── Load base data ────────────────────────────────────────────────
var jobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId && j.CreatedAt >= cutoff,
false,
j => j.Customer,
j => j.JobStatus))
.ToList();
var jobIds = jobs.Select(j => j.Id).ToList();
// Time entries grouped by job
var timeEntries = (await _unitOfWork.JobTimeEntries.FindAsync(
te => jobIds.Contains(te.JobId) && !te.IsDeleted))
.GroupBy(te => te.JobId)
.ToDictionary(g => g.Key, g => g.Sum(te => te.HoursWorked));
// Job items, then coats grouped by job
var jobItems = (await _unitOfWork.JobItems.FindAsync(
ji => jobIds.Contains(ji.JobId) && !ji.IsDeleted))
.ToList();
var jobItemIds = jobItems.Select(ji => ji.Id).ToList();
var coats = jobItemIds.Any()
? (await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId) && !c.IsDeleted))
.ToList()
: new List<JobItemCoat>();
// Map coat cost totals back to JobId
var jobItemToJob = jobItems.ToDictionary(ji => ji.Id, ji => ji.JobId);
var powderCostByJob = coats
.Where(c => c.PowderCostPerLb.HasValue)
.GroupBy(c => jobItemToJob.TryGetValue(c.JobItemId, out var jid) ? jid : 0)
.Where(g => g.Key > 0)
.ToDictionary(
g => g.Key,
g => g.Sum(c =>
{
var lbs = c.ActualPowderUsedLbs ?? c.PowderToOrder ?? 0m;
return lbs * (c.PowderCostPerLb ?? 0m);
}));
// Invoices grouped by job
var invoices = (await _unitOfWork.Invoices.FindAsync(
inv => inv.CompanyId == companyId && inv.JobId.HasValue
&& jobIds.Contains(inv.JobId!.Value)
&& !inv.IsDeleted))
.GroupBy(inv => inv.JobId!.Value)
.ToDictionary(g => g.Key, g => g.Sum(inv => inv.AmountPaid));
// Labor rate
var opCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(
c => c.CompanyId == companyId && !c.IsDeleted);
var laborRate = opCosts?.StandardLaborRate ?? 0m;
// ── Build report rows ─────────────────────────────────────────────
var items = jobs
.Select(j =>
{
var hours = timeEntries.TryGetValue(j.Id, out var h) ? h : 0m;
var powderCost = powderCostByJob.TryGetValue(j.Id, out var pc) ? pc : 0m;
var collected = invoices.TryGetValue(j.Id, out var paid) ? paid : 0m;
return new JobProfitabilityItem
{
JobId = j.Id,
JobNumber = j.JobNumber,
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim()
is { Length: > 0 } n ? n : "Unknown",
StatusName = j.JobStatus?.DisplayName ?? "Unknown",
StatusColorClass = j.JobStatus?.ColorClass ?? "bg-secondary",
JobDate = j.CreatedAt,
FinalPrice = j.FinalPrice,
AmountCollected = collected,
ActualHours = hours,
ActualLaborCost = Math.Round(hours * laborRate, 2),
ActualPowderCost = Math.Round(powderCost, 2),
HasTimeEntries = timeEntries.ContainsKey(j.Id),
};
})
.Where(r => !timeTrackedOnly || r.HasTimeEntries)
.OrderByDescending(r => r.JobDate)
.ToList();
// ── Summary KPIs ──────────────────────────────────────────────────
var itemsWithCost = items.Where(r => r.EstimatedTotalCost > 0).ToList();
return View(new JobProfitabilityViewModel
{
ReportTitle = "Job Profitability",
ReportDescription = "Actual labor and powder cost vs. billed price per job",
SelectedMonths = months,
TimeTrackedOnly = timeTrackedOnly,
TotalJobs = items.Count,
JobsWithTimeEntries = items.Count(r => r.HasTimeEntries),
TotalRevenue = items.Sum(r => r.FinalPrice),
TotalCollected = items.Sum(r => r.AmountCollected),
TotalEstimatedCost = itemsWithCost.Sum(r => r.EstimatedTotalCost),
TotalActualHours = items.Sum(r => r.ActualHours),
AvgMarginPercent = itemsWithCost.Any()
? Math.Round(itemsWithCost.Average(r => r.MarginPercent), 1)
: 0m,
Items = items,
});
}
/// <summary> /// <summary>
/// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current /// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
/// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status" /// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"
@@ -2598,231 +2475,6 @@ public class ReportsController : Controller
return View(rows.OrderByDescending(r => r.TotalPaid).ToList()); return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
} }
// =========================================================================
// Timeclock / Attendance
// =========================================================================
/// <summary>
/// Attendance report: daily punch detail and weekly summaries per employee.
/// Managers can see all employees; Workers see their own history only.
/// </summary>
[HttpGet]
public async Task<IActionResult> Attendance(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var period = ResolveAttendancePeriod(mode, week, month);
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= period.Start
&& e.ClockInTime < period.End
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
var grouped = entries
.GroupBy(e => e.UserId)
.Select(g => new AttendanceEmployeeRow
{
UserId = g.Key,
DisplayName = g.First().User?.FullName ?? "Unknown",
// Only Work segments count toward paid hours
TotalHours = Math.Round(g.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Days = g.GroupBy(e => e.ClockInTime.Date)
.OrderByDescending(d => d.Key)
.Select(d => new AttendanceDayRow
{
Date = d.Key,
DayTotal = Math.Round(d.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
Segments = d.OrderBy(e => e.ClockInTime).ToList()
}).ToList()
})
.OrderBy(r => r.DisplayName)
.ToList();
ViewBag.Period = period;
ViewBag.IsManager = isManager;
return View(grouped);
}
/// <summary>
/// Exports the attendance data for the selected date range as a CSV file suitable for
/// import into payroll software. Each row is one clock segment (punch pair); employee name,
/// date, clock in/out times, segment hours, day total, and week total are all repeated on
/// every row so the file is fully self-contained in flat-file format.
/// </summary>
[HttpGet]
public async Task<IActionResult> AttendanceCsv(string? mode, string? week, string? month)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var period = ResolveAttendancePeriod(mode, week, month);
var start = period.Start;
var end = period.End;
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& (isManager || e.UserId == currentUser.Id),
false,
e => e.User);
// Build flat CSV rows sorted by employee name → date → clock-in time
var rows = entries
.OrderBy(e => e.User?.FullName ?? "")
.ThenBy(e => e.ClockInTime)
.ToList();
// Day/week totals count only Work segments (Break/Lunch are unpaid)
var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();
var dayTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
// ISO week: group by (UserId, year, week number) where week starts Monday
static int IsoWeek(DateTime d) =>
System.Globalization.ISOWeek.GetWeekOfYear(d);
var weekTotals = workEntries
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
var sb = new System.Text.StringBuilder();
sb.AppendLine("Employee Name,Date,Day of Week,Type,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
foreach (var entry in rows)
{
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
var entryType = entry.EntryType.ToString(); // Work, Break, or Lunch
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
var clockOut = entry.ClockOutTime.HasValue
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
: "In Progress";
var segHrs = entry.HoursWorked.HasValue
? entry.HoursWorked.Value.ToString("F2")
: "";
// Day/week totals are blank for Break/Lunch rows — they only appear on Work rows
var isWork = entry.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work;
var dayKey = (entry.UserId, entry.ClockInTime.Date);
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
var dayTotal = isWork && dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
var wkTotal = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
var notes = CsvEscape(entry.Notes ?? "");
sb.AppendLine($"{name},{date},{dow},{entryType},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
var safePeriod = period.PeriodLabel.Replace(" ", "_").Replace("", "-").Replace(",", "");
var filename = $"{safeName}_Attendance_{safePeriod}.csv";
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
// =========================================================================
// Attendance period helpers
// =========================================================================
/// <summary>
/// Resolves query start/end dates and picker display state from the attendance
/// report's mode/week/month URL params. Defaults to the current ISO week.
/// Shared by <see cref="Attendance"/> and <see cref="AttendanceCsv"/> so both
/// actions always query exactly the same date range for a given URL.
/// </summary>
private static AttendancePeriod ResolveAttendancePeriod(string? mode, string? week, string? month)
{
if (mode?.ToLower() == "month")
{
var monthDate = month != null
&& DateTime.TryParseExact(month + "-01", "yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var md)
? md
: new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
return new AttendancePeriod
{
Mode = "month",
Start = monthDate,
End = monthDate.AddMonths(1),
WeekValue = IsoWeekValue(DateTime.UtcNow),
MonthValue = monthDate.ToString("yyyy-MM"),
PeriodLabel = monthDate.ToString("MMMM yyyy")
};
}
else
{
var weekStart = TryParseIsoWeek(week) ?? CurrentIsoWeekStart();
var weekEnd = weekStart.AddDays(6);
var label = weekStart.Year != weekEnd.Year
? $"{weekStart:MMM d, yyyy} {weekEnd:MMM d, yyyy}"
: weekStart.Month != weekEnd.Month
? $"Week of {weekStart:MMM d} {weekEnd:MMM d, yyyy}"
: $"Week of {weekStart:MMMM d} {weekEnd:d, yyyy}";
return new AttendancePeriod
{
Mode = "week",
Start = weekStart,
End = weekStart.AddDays(7),
WeekValue = IsoWeekValue(weekStart),
MonthValue = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).ToString("yyyy-MM"),
PeriodLabel = label
};
}
}
/// <summary>Returns the Monday of the current ISO week in UTC.</summary>
private static DateTime CurrentIsoWeekStart()
{
var today = DateTime.UtcNow.Date;
return System.Globalization.ISOWeek.ToDateTime(
System.Globalization.ISOWeek.GetYear(today),
System.Globalization.ISOWeek.GetWeekOfYear(today),
DayOfWeek.Monday);
}
/// <summary>Formats a date as the ISO week string used by the HTML week picker (e.g. "2026-W22").</summary>
private static string IsoWeekValue(DateTime d) =>
$"{System.Globalization.ISOWeek.GetYear(d):D4}-W{System.Globalization.ISOWeek.GetWeekOfYear(d):D2}";
/// <summary>Parses an ISO week string (e.g. "2026-W22") to the Monday of that week. Returns null on failure.</summary>
private static DateTime? TryParseIsoWeek(string? value)
{
if (value == null) return null;
var parts = value.Split("-W");
if (parts.Length == 2
&& int.TryParse(parts[0], out int yr) && yr is >= 2020 and <= 2100
&& int.TryParse(parts[1], out int wk) && wk is >= 1 and <= 53)
{
try { return System.Globalization.ISOWeek.ToDateTime(yr, wk, DayOfWeek.Monday); }
catch { /* invalid week number for that year */ }
}
return null;
}
/// <summary> /// <summary>
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the /// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
/// company name into AI prompts so the generated text refers to the actual business, not a /// company name into AI prompts so the generated text refers to the actual business, not a
@@ -2976,30 +2628,3 @@ public class Vendor1099Row
public bool NeedsForm { get; set; } public bool NeedsForm { get; set; }
} }
public class AttendanceEmployeeRow
{
public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public decimal TotalHours { get; set; }
public List<AttendanceDayRow> Days { get; set; } = new();
}
public class AttendanceDayRow
{
public DateTime Date { get; set; }
public decimal DayTotal { get; set; }
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
}
/// <summary>Resolved date range and picker state for the attendance report.</summary>
public record AttendancePeriod
{
public string Mode { get; init; } = "week";
public DateTime Start { get; init; }
/// <summary>Exclusive upper bound for EF queries (Start + 7 days or +1 month).</summary>
public DateTime End { get; init; }
public string WeekValue { get; init; } = ""; // "2026-W22"
public string MonthValue { get; init; } = ""; // "2026-05"
public string PeriodLabel { get; init; } = "";
}
@@ -301,9 +301,6 @@ public class SubscriptionManagementController : Controller
case "AllowOnlinePayments": case "AllowOnlinePayments":
config.AllowOnlinePayments = enabled; config.AllowOnlinePayments = enabled;
break; break;
case "AllowCustomFormulas":
config.AllowCustomFormulas = enabled;
break;
default: default:
return BadRequest("Unknown feature."); return BadRequest("Unknown feature.");
} }
@@ -1,816 +0,0 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Timeclock;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles employee facility-level timeclock: clock in/out, manager edits, and the
/// tablet kiosk page with PIN-based authentication.
/// Two authentication modes:
/// - Main app actions: standard [Authorize] cookie auth (normal nav users)
/// - Kiosk actions: device-cookie auth (shared tablet, no user login required)
/// </summary>
[Authorize]
public class TimeclockController : Controller
{
private const string KioskCookieName = "TimeclockKioskDevice";
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly ILogger<TimeclockController> _logger;
/// <summary>Initialises dependencies for the timeclock controller.</summary>
public TimeclockController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
IPasswordHasher<ApplicationUser> passwordHasher,
ILogger<TimeclockController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_passwordHasher = passwordHasher;
_logger = logger;
}
// =========================================================================
// MAIN APP — dashboard
// =========================================================================
/// <summary>
/// Timeclock dashboard: current user's punch button, "who's in" grid, and personal history.
/// </summary>
public async Task<IActionResult> Index()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
// Current user's open entry (null = clocked out)
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == currentUser.Id && e.ClockOutTime == null && e.CompanyId == companyId);
// All open entries for the "Who's In" table
var activeEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId,
false,
e => e.User);
// Current user's last 14 days of entries
var since = DateTime.UtcNow.Date.AddDays(-14);
var myHistory = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == currentUser.Id && e.ClockInTime >= since && e.CompanyId == companyId);
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
// Load company employees for the manual-entry dropdown (managers only)
List<(string Id, string Name)> companyUsers = new();
if (isManager)
{
companyUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.Select(u => new { u.Id, u.FirstName, u.LastName })
.ToListAsync()
.ContinueWith(t => t.Result
.Select(u => (u.Id, $"{u.FirstName} {u.LastName}".Trim()))
.ToList());
}
ViewBag.CurrentUser = currentUser;
ViewBag.OpenEntry = openEntry != null ? MapEntry(openEntry, currentUser) : null;
ViewBag.ActiveEntries = activeEntries.Select(e => MapEntry(e, e.User)).OrderBy(e => e.ClockInTime).ToList();
ViewBag.MyHistory = myHistory.OrderByDescending(e => e.ClockInTime).ToList();
ViewBag.IsManager = isManager;
ViewBag.NowUtc = DateTime.UtcNow;
ViewBag.CompanyUsers = companyUsers;
ViewBag.AllowMultiplePunches = company?.TimeclockAllowMultiplePunchesPerDay ?? true;
return View();
}
// =========================================================================
// MAIN APP — clock in / out / status
// =========================================================================
/// <summary>Returns the current user's open clock entry, or null if clocked out.</summary>
[HttpGet]
public async Task<IActionResult> MyStatus()
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null) return Json(new { isClockedIn = false });
var elapsed = (decimal)(DateTime.UtcNow - openEntry.ClockInTime).TotalHours;
return Json(new
{
isClockedIn = true,
entryId = openEntry.Id,
clockInTime = openEntry.ClockInTime,
elapsedHours = Math.Round(elapsed, 2)
});
}
/// <summary>Returns all employees with an open clock entry (for the "Who's In" widget).</summary>
[HttpGet]
public async Task<IActionResult> ActiveNow()
{
var companyId = GetCurrentCompanyId();
var active = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId,
false,
e => e.User);
var result = active.Select(e => new
{
userId = e.UserId,
displayName = e.User?.FullName ?? "Unknown",
clockInTime = e.ClockInTime,
entryId = e.Id,
entryType = (int)e.EntryType // 0=Work, 1=Break, 2=Lunch
});
return Json(result);
}
/// <summary>
/// Clocks the current user in. Enforces three company-level timeclock settings:
/// (1) auto-closes stale open entries if AutoClockOutHours is set,
/// (2) blocks a second punch today if AllowMultiplePunchesPerDay is false,
/// (3) blocks immediately if already clocked in.
/// </summary>
[HttpPost]
public async Task<IActionResult> ClockIn([FromBody] ClockInRequest request)
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (company == null) return Forbid();
var now = DateTime.UtcNow;
// Auto clock-out stale open entries if configured
if (company.TimeclockAutoClockOutHours is > 0)
{
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == userId && e.ClockOutTime == null
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
foreach (var staleEntry in stale)
{
staleEntry.ClockOutTime = staleEntry.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
staleEntry.HoursWorked = company.TimeclockAutoClockOutHours.Value;
staleEntry.Notes = (staleEntry.Notes + " [Auto clocked out]").Trim();
}
if (stale.Any()) await _unitOfWork.CompleteAsync();
}
// Still clocked in?
var alreadyOpen = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (alreadyOpen != null)
return BadRequest(new { message = "You are already clocked in. Please clock out first." });
// Block second punch today if setting disallows it
if (!company.TimeclockAllowMultiplePunchesPerDay)
{
var todayStart = now.Date;
var punchedToday = await _unitOfWork.EmployeeClockEntries.AnyAsync(
e => e.UserId == userId && e.CompanyId == companyId && e.ClockInTime >= todayStart);
if (punchedToday)
return BadRequest(new { message = "Multiple clock-ins per day are not allowed. You have already clocked in today." });
}
var entry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = now,
Notes = request.Notes?.Trim()
};
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} clocked in (entry {EntryId})", userId, entry.Id);
return Json(new { success = true, entryId = entry.Id, clockInTime = entry.ClockInTime });
}
/// <summary>Clocks the current user out, storing the elapsed hours on the entry.</summary>
[HttpPost]
public async Task<IActionResult> ClockOut([FromBody] ClockOutRequest request)
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.EntryId);
if (entry == null || entry.UserId != userId || entry.CompanyId != companyId)
return NotFound(new { message = "Clock entry not found." });
if (entry.ClockOutTime != null)
return BadRequest(new { message = "This entry is already clocked out." });
var now = DateTime.UtcNow;
entry.ClockOutTime = now;
entry.HoursWorked = Math.Round((decimal)(now - entry.ClockInTime).TotalHours, 2);
if (!string.IsNullOrWhiteSpace(request.Notes))
entry.Notes = request.Notes.Trim();
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} clocked out (entry {EntryId}, {Hours}h)", userId, entry.Id, entry.HoursWorked);
return Json(new { success = true, hoursWorked = entry.HoursWorked, clockOutTime = entry.ClockOutTime });
}
// =========================================================================
// MAIN APP — manager: history, edit, delete
// =========================================================================
/// <summary>Returns paginated clock entries. Managers can pass any userId; others only see their own.</summary>
[HttpGet]
public async Task<IActionResult> History(DateTime? from, DateTime? to, string? userId)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var companyId = currentUser.CompanyId;
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
var filterUser = isManager ? userId : currentUser.Id;
var start = (from ?? DateTime.UtcNow.AddDays(-30)).Date;
var end = (to ?? DateTime.UtcNow).Date.AddDays(1);
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.CompanyId == companyId
&& e.ClockInTime >= start
&& e.ClockInTime < end
&& (filterUser == null || e.UserId == filterUser),
false,
e => e.User);
var dtos = entries.OrderByDescending(e => e.ClockInTime)
.Select(e => MapEntry(e, e.User))
.ToList();
return Json(dtos);
}
/// <summary>Manager-only: edit clock-in/out times or notes on any entry in the company.</summary>
[HttpPost]
public async Task<IActionResult> Edit([FromBody] EditClockEntryRequest request)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(request.Id);
if (entry == null || entry.CompanyId != companyId)
return NotFound(new { message = "Entry not found." });
entry.ClockInTime = request.ClockInTime;
entry.ClockOutTime = request.ClockOutTime;
entry.Notes = request.Notes?.Trim();
if (request.ClockOutTime.HasValue)
entry.HoursWorked = Math.Round(
(decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2);
else
entry.HoursWorked = null;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Manager-only: soft-delete a clock entry.</summary>
[HttpPost]
public async Task<IActionResult> Delete([FromBody] int id)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var entry = await _unitOfWork.EmployeeClockEntries.GetByIdAsync(id);
if (entry == null || entry.CompanyId != companyId)
return NotFound(new { message = "Entry not found." });
await _unitOfWork.EmployeeClockEntries.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>
/// Closes the current open Work entry and opens a Break or Lunch entry.
/// The caller must have an open Work segment; calling while already on a break returns 400.
/// Multi-punches-per-day setting is intentionally bypassed — taking a break is inherently
/// a second punch in the same day and should always be allowed.
/// </summary>
[HttpPost]
public async Task<IActionResult> GoOnBreak([FromBody] GoOnBreakRequest request)
{
if (request.BreakType is not (ClockEntryType.Break or ClockEntryType.Lunch))
return BadRequest(new { message = "Invalid break type." });
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null)
return BadRequest(new { message = "You are not currently clocked in." });
if (openEntry.EntryType != ClockEntryType.Work)
return BadRequest(new { message = "You are already on a break." });
var now = DateTime.UtcNow;
// Close the work segment
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
// Open the break/lunch segment
var breakEntry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = now,
EntryType = request.BreakType
};
await _unitOfWork.EmployeeClockEntries.AddAsync(breakEntry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} started {BreakType} (entry {EntryId})", userId, request.BreakType, breakEntry.Id);
return Json(new { success = true, entryId = breakEntry.Id });
}
/// <summary>
/// Closes the current open Break/Lunch entry and opens a new Work entry.
/// The caller must have an open Break or Lunch segment; calling while working returns 400.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReturnFromBreak()
{
var userId = _userManager.GetUserId(User)!;
var companyId = GetCurrentCompanyId();
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
if (openEntry == null || openEntry.EntryType == ClockEntryType.Work)
return BadRequest(new { message = "You are not currently on a break." });
var now = DateTime.UtcNow;
// Close the break segment
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
// Open a new work segment
var workEntry = new EmployeeClockEntry
{
UserId = userId,
CompanyId = companyId,
ClockInTime = now,
EntryType = ClockEntryType.Work
};
await _unitOfWork.EmployeeClockEntries.AddAsync(workEntry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("User {UserId} returned from {BreakType} (new entry {EntryId})", userId, openEntry.EntryType, workEntry.Id);
return Json(new { success = true, entryId = workEntry.Id });
}
/// <summary>
/// Manager-only: create a manual time entry for any active employee in the company.
/// Used to record missed punches, paper timesheets, or schedule corrections.
/// ClockInTime and ClockOutTime are expected as UTC ISO strings from the JS client.
/// </summary>
[HttpPost]
public async Task<IActionResult> ManualEntry([FromBody] ManualEntryRequest request)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
// Verify the target user belongs to this company
var targetUser = await _userManager.FindByIdAsync(request.UserId);
if (targetUser == null || targetUser.CompanyId != companyId || !targetUser.IsActive)
return NotFound(new { message = "Employee not found." });
if (request.ClockOutTime.HasValue && request.ClockOutTime.Value <= request.ClockInTime)
return BadRequest(new { message = "Clock-out time must be after clock-in time." });
var entry = new EmployeeClockEntry
{
UserId = request.UserId,
CompanyId = companyId,
ClockInTime = request.ClockInTime,
ClockOutTime = request.ClockOutTime,
Notes = request.Notes?.Trim()
};
if (request.ClockOutTime.HasValue)
entry.HoursWorked = Math.Round(
(decimal)(request.ClockOutTime.Value - request.ClockInTime).TotalHours, 2);
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Manager {ManagerId} created manual entry for {TargetUserId} (entry {EntryId})",
_userManager.GetUserId(User), request.UserId, entry.Id);
return Json(new { success = true, entryId = entry.Id });
}
// =========================================================================
// KIOSK — device activation
// =========================================================================
/// <summary>
/// Shows the kiosk activation form where a manager can optionally name the device
/// before activating it. Navigate to this URL on each tablet to register it.
/// </summary>
[HttpGet("Timeclock/Kiosk/Activate")]
public async Task<IActionResult> KioskActivate()
{
if (!await IsManagerAsync()) return Forbid();
return View();
}
/// <summary>
/// Creates a new <see cref="TimeclockKioskDevice"/> row and writes the device cookie for
/// this tablet. Multiple devices per company are supported — each tablet gets its own token.
/// </summary>
[HttpPost("Timeclock/Kiosk/Activate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> KioskActivatePost(string? deviceName)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var token = Guid.NewGuid().ToString("N");
var device = new TimeclockKioskDevice
{
CompanyId = companyId,
Token = token,
DeviceName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim(),
ActivatedAt = DateTime.UtcNow
};
await _unitOfWork.TimeclockKioskDevices.AddAsync(device);
await _unitOfWork.CompleteAsync();
WriteTimeclockKioskCookie(companyId, token);
TempData["Success"] = "Kiosk activated on this device.";
return RedirectToAction(nameof(Kiosk));
}
/// <summary>Deactivates a specific kiosk device by ID. Deletes its row so the cookie is invalidated on next use.</summary>
[HttpPost]
public async Task<IActionResult> KioskDeactivate(int id)
{
if (!await IsManagerAsync()) return Json(new { success = false, message = "Forbidden." });
var companyId = GetCurrentCompanyId();
var device = await _unitOfWork.TimeclockKioskDevices.GetByIdAsync(id);
if (device == null || device.CompanyId != companyId)
return Json(new { success = false, message = "Device not found." });
await _unitOfWork.TimeclockKioskDevices.DeleteAsync(device);
await _unitOfWork.CompleteAsync();
// If the current browser holds this device's cookie, clear it too
var cookie = ReadTimeclockKioskCookie();
if (cookie?.token == device.Token)
DeleteTimeclockKioskCookie();
return Json(new { success = true });
}
/// <summary>Returns the list of active kiosk devices for this company. Used by the Settings tab.</summary>
[HttpGet]
public async Task<IActionResult> KioskDevices()
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var devices = await _unitOfWork.TimeclockKioskDevices.FindAsync(
d => d.CompanyId == companyId);
var result = devices.OrderBy(d => d.ActivatedAt).Select(d => new
{
id = d.Id,
deviceName = d.DeviceName ?? "Unnamed Device",
activatedAt = d.ActivatedAt,
lastSeenAt = d.LastSeenAt
});
return Json(result);
}
// =========================================================================
// KIOSK — tablet UI
// =========================================================================
/// <summary>
/// Tablet timeclock kiosk page. Requires device-cookie auth; no user login needed.
/// Validates the cookie token against <see cref="TimeclockKioskDevice"/> rows (supports multiple tablets per company).
/// </summary>
[AllowAnonymous]
[HttpGet("Timeclock/Kiosk")]
public async Task<IActionResult> Kiosk()
{
var (device, company) = await ValidateKioskCookieAsync();
if (device == null || company == null)
return View("KioskError", "This device is not activated as a timeclock kiosk. Ask a manager to activate it at Timeclock &rsaquo; Activate Kiosk.");
if (!company.TimeclockEnabled)
return View("KioskError", "Timeclock is currently disabled for this company.");
await TouchDeviceAsync(device);
ViewBag.CompanyName = company.CompanyName;
ViewBag.CompanyId = company.Id;
return View();
}
/// <summary>
/// Returns the list of active employees who have a kiosk PIN set, along with their current clock-in status.
/// Called by the kiosk page on load and after each punch.
/// </summary>
[AllowAnonymous, HttpGet]
public async Task<IActionResult> KioskEmployees()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return Forbid();
// Validate device (no need to touch LastSeenAt on every poll)
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
true);
if (device == null) return Forbid();
var companyId = cookie.Value.companyId;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.KioskPin != null)
.ToListAsync();
// Get current open entries for this company
var openUserIds = (await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.ClockOutTime == null && e.CompanyId == companyId))
.Select(e => e.UserId)
.ToHashSet();
var employees = users.Select(u => new KioskEmployeeDto
{
UserId = u.Id,
DisplayName = u.FullName,
Initials = BuildInitials(u),
IsClockedIn = openUserIds.Contains(u.Id)
}).OrderBy(e => e.DisplayName).ToList();
return Json(employees);
}
/// <summary>
/// Kiosk punch endpoint: validates PIN and clocks the employee in or out automatically.
/// Returns the action taken ("clockIn" or "clockOut") and the segment/daily totals.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> KioskPunch([FromBody] KioskPunchRequest request)
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return Forbid();
var companyId = cookie.Value.companyId;
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == companyId && d.Token == cookie.Value.token, true);
if (device == null) return Forbid();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || !company.TimeclockEnabled) return Forbid();
var user = await _userManager.FindByIdAsync(request.UserId);
if (user == null || user.CompanyId != companyId || !user.IsActive || user.KioskPin == null)
return BadRequest(new { message = "Employee not found or kiosk disabled." });
// Verify PIN
var hashResult = _passwordHasher.VerifyHashedPassword(user, user.KioskPin, request.Pin);
if (hashResult == PasswordVerificationResult.Failed)
return BadRequest(new { message = "Incorrect PIN. Please try again." });
var now = DateTime.UtcNow;
// Auto clock-out stale open entries (shared logic with main-app ClockIn)
if (company.TimeclockAutoClockOutHours is > 0)
{
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == user.Id && e.ClockOutTime == null
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
foreach (var s in stale)
{
s.ClockOutTime = s.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
s.HoursWorked = company.TimeclockAutoClockOutHours.Value;
s.Notes = (s.Notes + " [Auto clocked out]").Trim();
}
if (stale.Any()) await _unitOfWork.CompleteAsync();
}
// Check for open entry
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
e => e.UserId == user.Id && e.ClockOutTime == null && e.CompanyId == companyId);
string action;
decimal segmentHours = 0;
if (openEntry != null)
{
// Clock out
openEntry.ClockOutTime = now;
openEntry.HoursWorked = Math.Round((decimal)(now - openEntry.ClockInTime).TotalHours, 2);
await _unitOfWork.CompleteAsync();
action = "clockOut";
segmentHours = openEntry.HoursWorked.Value;
}
else
{
// Block second punch today if not allowed
if (!company.TimeclockAllowMultiplePunchesPerDay)
{
var todayPunched = await _unitOfWork.EmployeeClockEntries.AnyAsync(
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= now.Date);
if (todayPunched)
return BadRequest(new { message = "Multiple clock-ins per day are not allowed." });
}
// Clock in
var entry = new EmployeeClockEntry
{
UserId = user.Id,
CompanyId = companyId,
ClockInTime = now
};
await _unitOfWork.EmployeeClockEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
action = "clockIn";
}
// Compute today's running total (all completed segments + any still-open segment)
var todayStart = now.Date;
var todayEntries = await _unitOfWork.EmployeeClockEntries.FindAsync(
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= todayStart);
decimal dailyTotal = todayEntries.Sum(e =>
e.HoursWorked ?? (decimal)(now - e.ClockInTime).TotalHours);
dailyTotal = Math.Round(dailyTotal, 2);
return Json(new
{
action,
displayName = user.FullName,
timestamp = now,
segmentHours,
dailyTotal,
segmentCount = todayEntries.Count()
});
}
// =========================================================================
// PIN management (called from Users UI via AJAX)
// =========================================================================
/// <summary>Sets or clears a 4-digit kiosk PIN for the specified employee. Manager-only.</summary>
[HttpPost]
public async Task<IActionResult> SetKioskPin(string userId, string? pin)
{
if (!await IsManagerAsync()) return Forbid();
var companyId = GetCurrentCompanyId();
var user = await _userManager.FindByIdAsync(userId);
if (user == null || user.CompanyId != companyId)
return NotFound(new { message = "User not found." });
if (string.IsNullOrWhiteSpace(pin))
{
user.KioskPin = null;
}
else
{
if (pin.Length != 4 || !pin.All(char.IsDigit))
return BadRequest(new { message = "PIN must be exactly 4 digits." });
user.KioskPin = _passwordHasher.HashPassword(user, pin);
}
await _userManager.UpdateAsync(user);
return Json(new { success = true });
}
// =========================================================================
// Helpers
// =========================================================================
/// <summary>
/// Validates the kiosk device cookie against the <see cref="TimeclockKioskDevice"/> table.
/// Returns (null, null) if the cookie is absent or the token no longer exists.
/// Queries with ignoreQueryFilters because the kiosk runs without a logged-in user context.
/// </summary>
private async Task<(TimeclockKioskDevice? device, Company? company)> ValidateKioskCookieAsync()
{
var cookie = ReadTimeclockKioskCookie();
if (cookie == null) return (null, null);
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
true);
if (device == null) return (null, null);
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
return (device, company);
}
/// <summary>Updates <see cref="TimeclockKioskDevice.LastSeenAt"/> without blocking the response.</summary>
private async Task TouchDeviceAsync(TimeclockKioskDevice device)
{
device.LastSeenAt = DateTime.UtcNow;
await _unitOfWork.TimeclockKioskDevices.UpdateAsync(device);
await _unitOfWork.CompleteAsync();
}
private int GetCurrentCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out int id) ? id : 0;
}
private async Task<bool> IsManagerAsync()
{
var user = await _userManager.GetUserAsync(User);
return user?.CompanyRole is "CompanyAdmin" or "Manager";
}
private (int companyId, string token)? ReadTimeclockKioskCookie()
{
if (!Request.Cookies.TryGetValue(KioskCookieName, out var raw) || string.IsNullOrEmpty(raw))
return null;
var parts = raw.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
return null;
return (id, parts[1]);
}
private void WriteTimeclockKioskCookie(int companyId, string token)
{
Response.Cookies.Append(KioskCookieName, $"{companyId}:{token}", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(365)
});
}
private void DeleteTimeclockKioskCookie()
{
Response.Cookies.Delete(KioskCookieName);
}
private static EmployeeClockEntryDto MapEntry(EmployeeClockEntry e, ApplicationUser? user) =>
new()
{
Id = e.Id,
UserId = e.UserId,
UserDisplayName = user?.FullName ?? "Unknown",
ClockInTime = e.ClockInTime,
ClockOutTime = e.ClockOutTime,
HoursWorked = e.HoursWorked,
EntryType = e.EntryType,
Notes = e.Notes
};
private static string BuildInitials(ApplicationUser u)
{
var first = string.IsNullOrEmpty(u.FirstName) ? "" : u.FirstName[0].ToString();
var last = string.IsNullOrEmpty(u.LastName) ? "" : u.LastName[0].ToString();
return (first + last).ToUpperInvariant();
}
}
@@ -181,7 +181,6 @@ public class VendorsController : Controller
public async Task<IActionResult> Create(bool inline = false) public async Task<IActionResult> Create(bool inline = false)
{ {
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync();
if (inline) if (inline)
return PartialView(new CreateVendorDto()); return PartialView(new CreateVendorDto());
return View(new CreateVendorDto()); return View(new CreateVendorDto());
@@ -208,36 +207,14 @@ public class VendorsController : Controller
return Json(new { success = false, errors }); return Json(new { success = false, errors });
} }
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
try try
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser!.CompanyId;
var duplicate = await _unitOfWork.Vendors.FirstOrDefaultAsync(
v => v.CompanyId == companyId && v.CompanyName.ToLower() == dto.CompanyName.ToLower());
if (duplicate != null)
{
var msg = $"A vendor named '{dto.CompanyName}' already exists.";
if (inline)
return Json(new { success = false, errors = new[] { msg } });
ModelState.AddModelError(nameof(dto.CompanyName), msg);
await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto);
}
var vendor = _mapper.Map<Vendor>(dto); var vendor = _mapper.Map<Vendor>(dto);
vendor.CompanyId = companyId; vendor.CompanyId = currentUser!.CompanyId;
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
vendor.Categories = cats.ToList();
}
await _unitOfWork.Vendors.AddAsync(vendor); await _unitOfWork.Vendors.AddAsync(vendor);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -270,16 +247,14 @@ public class VendorsController : Controller
try try
{ {
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories); var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
if (vendor == null) if (vendor == null)
{ {
return NotFound(); return NotFound();
} }
var dto = _mapper.Map<UpdateVendorDto>(vendor); var dto = _mapper.Map<UpdateVendorDto>(vendor);
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
catch (Exception ex) catch (Exception ex)
@@ -307,27 +282,18 @@ public class VendorsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
await PopulateExpenseAccountsAsync(); await PopulateExpenseAccountsAsync();
await PopulateVendorCategoriesAsync(dto.CategoryIds);
return View(dto); return View(dto);
} }
try try
{ {
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories); var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
if (vendor == null) if (vendor == null)
{ {
return NotFound(); return NotFound();
} }
_mapper.Map(dto, vendor); _mapper.Map(dto, vendor);
vendor.Categories.Clear();
if (dto.CategoryIds.Any())
{
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
foreach (var cat in cats) vendor.Categories.Add(cat);
}
await _unitOfWork.Vendors.UpdateAsync(vendor); await _unitOfWork.Vendors.UpdateAsync(vendor);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -447,20 +413,6 @@ public class VendorsController : Controller
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so /// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
/// the field is optional — not every vendor needs a default account pre-set. /// the field is optional — not every vendor needs a default account pre-set.
/// </summary> /// </summary>
/// <summary>
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
/// </summary>
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
{
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
.OrderBy(c => c.DisplayOrder)
.ToList();
ViewBag.VendorCategories = cats;
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
}
private async Task PopulateExpenseAccountsAsync() private async Task PopulateExpenseAccountsAsync()
{ {
var accounts = (await _unitOfWork.Accounts.FindAsync( var accounts = (await _unitOfWork.Accounts.FindAsync(
@@ -197,14 +197,10 @@ public static class HelpKnowledgeBase
5. Review the pricing breakdown 5. Review the pricing breakdown
6. Save as Draft or Send immediately 6. Save as Draft or Send immediately
**Item types in the quote/job wizard:** **Three item types in the quote wizard:**
1. *Product from Catalog* pick a pre-priced catalog item; price is fixed, no surface-area calculation 1. *Calculated* you enter dimensions; system calculates surface area and price from operating costs
2. *Calculated* enter dimensions; system calculates surface area and price from operating costs; includes coatings and prep steps 2. *Custom Work* you set a manual price (for non-standard work)
3. *Flat-Rate Charge* set a fixed price manually; optionally enable "Specify powder coating" toggle to record color/powder details for tracking and ordering without affecting the flat price; always fill in the Color Name field so it appears in the Custom Powder Order description 3. *AI Photo Quote* upload photos; AI (Claude) analyzes the item and estimates surface area, complexity, and labor
4. *Labor Only* bill by the hour at the standard labor rate; no coating or prep step
5. *AI Photo Quote* upload photos; AI (Claude) analyzes the item and estimates surface area, complexity, and labor
6. *Merchandise* sell off-the-shelf items (T-shirts, tumblers, accessories) from your catalog at a fixed price
7. *Custom Formula* use a formula template from Company Settings to calculate price from exact measurements (e.g. length × width × height for a box)
**Pricing breakdown components:** **Pricing breakdown components:**
- Powder/material cost (cost per sq ft × surface area) - Powder/material cost (cost per sq ft × surface area)
@@ -217,12 +213,6 @@ public static class HelpKnowledgeBase
- Customer tier discount - Customer tier discount
- Tax (0% for tax-exempt customers) - Tax (0% for tax-exempt customers)
**Custom Powder Order ordering costs as a separate line item:** When a coat is configured with a manually entered cost per lb (no inventory item selected) or with a powder color that must be ordered (the "Order this color" path), the material cost for that powder is NOT added to the individual item price. Instead, the system auto-generates a separate **"Custom Powder Order"** line item that collects all ordering costs in one place. This lets you add shipping/freight to the total before presenting it to the customer.
- While building the quote, a dashed yellow **Powder Order** preview card appears below the item cards. It shows the calculated material cost with an editable price field. Enter the final amount you want to charge material plus any shipping before saving.
- On the saved quote, the Custom Powder Order appears as its own line item with the color name(s) in its description (e.g. "Custom Powder Order (Gloss Black, Satin Silver)"). Always fill in the Color Name field in the coating layer if you typed a color name in the catalog search box but did not select a catalog result, the wizard copies the typed text to Color Name automatically when you tab away. If no color name is entered, the item card shows a red "No color specified" badge as a reminder.
- A yellow banner on the Quote Details page reminds you when a Custom Powder Order is present so you don't forget to account for shipping.
- The Custom Powder Order is created only once (on first save). After that, the price is yours to edit the system will not overwrite it on subsequent saves.
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated. **Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
**Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page. **Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page.
@@ -246,8 +236,6 @@ public static class HelpKnowledgeBase
**Changing the customer on a quote:** On the Quote Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears asking you to confirm the change. Click **Save** to apply or **Cancel** to revert to the original. This is especially useful when a quote was created under the "Walk-In / Phone" placeholder and the real customer record is added later. **Changing the customer on a quote:** On the Quote Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears asking you to confirm the change. Click **Save** to apply or **Cancel** to revert to the original. This is especially useful when a quote was created under the "Walk-In / Phone" placeholder and the real customer record is added later.
**Quote Revision History:** Every change to a quote is recorded in a timeline at the bottom of the Quote Details page. Changes from the same save are grouped into one revision entry. Tracked events include: total price changes (shown as oldnew badge on the revision header), status transitions, sent/resent events (including recipient email), approvals, line item adds/removes/edits, and field changes (dates, terms, notes, tax, discount). Timeline icons: blue envelope = sent to customer, green check = approved, red X = rejected, purple arrow = converted to job, grey pencil = general edit.
--- ---
## JOBS ## JOBS
@@ -313,18 +301,7 @@ public static class HelpKnowledgeBase
**Time Entries:** Track labor time on a job from the Details page. **Time Entries:** Track labor time on a job from the Details page.
**Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it. **Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution.
Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility.
**Pricing responsibility options:**
- **Shop Fault no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault.
- **Customer responsible reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created.
- **Customer responsible full price:** Item prices are copied from the original job as-is.
**Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see.
**Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed.
**Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items. **Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
@@ -350,8 +327,6 @@ public static class HelpKnowledgeBase
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand. **Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Custom Powder Order ordering costs as a separate line item:** Same behavior as quotes. When a coat uses a custom or incoming powder (manually entered cost per lb, or a color ordered through the "Order this color" path), the material cost is separated from the item price and collected into a single **"Custom Powder Order"** line item. While building the job, a dashed yellow Powder Order preview card appears; edit its price before saving to include shipping. After the first save the price is user-owned and will not be overwritten.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record. **Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank. - Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line. - The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -480,21 +455,13 @@ public static class HelpKnowledgeBase
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item). Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
**QR Code Labels & Mobile Usage Logging:** **QR Code Labels & Mobile Usage Logging:**
- Print a QR label from any item's Details page OR from the QR icon in the Actions column on the Inventory list page click "Print QR Label" a preview modal opens with the label (item name, SKU, colour, finish, and QR code). Click "Print Label" inside the modal to send to your printer without opening a new tab. - Print a QR label from any item's Details page click "Print QR Label" opens a standalone print page with the item name, SKU, colour, and QR code. Print and stick on the bag/bin.
- You can also print from the list page without opening the item: click the QR icon (first button in the action column) next to any row.
- Scan the QR code with a phone camera opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id] - Scan the QR code with a phone camera opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save - On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details" - After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger - Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
- First-time scan on a new device requires login; browser caches the session after that - First-time scan on a new device requires login; browser caches the session after that
**Location / Bin filtering and printing:**
- Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form.
- Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously.
- Each location badge in the table is a clickable link that instantly filters to that bin.
- With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome prints cleanly on a standard sheet.
- The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item.
**Catalog Lookup & Label Scanner (when adding/editing inventory items):** **Catalog Lookup & Label Scanner (when adding/editing inventory items):**
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs. - When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown. - The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
@@ -633,7 +600,6 @@ public static class HelpKnowledgeBase
- *Ask Your Financials* [/Reports/FinancialQuery](/Reports/FinancialQuery) natural language query interface. Type any financial question ("What were my top expenses last quarter?", "Which customers owe the most?") and the AI answers using your real data. Includes suggestion chips, follow-up prompts, supporting facts, and session history. The right panel shows a YTD financial snapshot (revenue, expenses, net income, open AR, open AP). - *Ask Your Financials* [/Reports/FinancialQuery](/Reports/FinancialQuery) natural language query interface. Type any financial question ("What were my top expenses last quarter?", "Which customers owe the most?") and the AI answers using your real data. Includes suggestion chips, follow-up prompts, supporting facts, and session history. The right panel shows a YTD financial snapshot (revenue, expenses, net income, open AR, open AP).
- *Powder Usage Report* powder consumption by item/job - *Powder Usage Report* powder consumption by item/job
- *Job Cycle Time Report* how long jobs spend in each status - *Job Cycle Time Report* how long jobs spend in each status
- *Job Profitability Report* [/Reports/JobProfitability](/Reports/JobProfitability) compares each job's billed price against actual labor cost (logged hours × Standard Labor Rate) and estimated powder cost (lbs × cost/lb). Shows gross margin and margin % per job. Jobs without time entries show $0 labor cost; use the "Time-tracked jobs only" toggle to filter for accurate averages. Color-coded: green 40%, yellow 20%, red below 20%. This is a direct-cost margin only overhead and equipment are not broken out separately.
Most financial reports support PDF export. The Sales Tax Report also supports CSV export. Most financial reports support PDF export. The Sales Tax Report also supports CSV export.
@@ -1211,26 +1177,6 @@ public static class HelpKnowledgeBase
--- ---
## DATA IMPORT / EXPORT
**Where:** [Tools](/Tools) in the left sidebar under Admin Tools
**What it does:** Import records in bulk from CSV, or export your data for use in other systems (QuickBooks Desktop, QuickBooks Online, Excel/CSV). Use the step-by-step wizard: choose Import or Export choose format select data type upload or download.
**Importing Jobs from CSV:**
Required columns: JobNumber, CustomerEmail (or CustomerName), Status, Priority, ScheduledDate, DueDate, FinalPrice, CustomerPO, SpecialInstructions, Notes.
- **FinalPrice**: a number (e.g. 150.00) or blank for $0. Non-numeric values (e.g. a spreadsheet formula that resolves to "false") are treated as $0 with a warning the row is still imported.
- **CustomerEmail vs CustomerName**: email is tried first; if no match is found and CustomerName is provided, name is tried as a fallback. A warning is added when name-matching is used.
- **Status / Priority**: unrecognised values default to Pending / Normal with a warning.
- **Duplicate job numbers**: rows matching an existing job number are skipped with a warning.
After import, the results page shows a summary of rows imported, skipped, and any warnings or errors per row.
**Importing Customers, Inventory, Quotes, Appointments:** Use the same wizard select CSV format and then choose the record type. Each type has a downloadable template (click "Download Template" in the wizard) showing the expected column headers.
**Exporting data:** Choose Export format (CSV, QuickBooks Desktop .IIF, QuickBooks Online .CSV, Excel) select which data types to include download. All exports respect your company's tenant filter you only export your own data.
---
## AUDIT LOG ## AUDIT LOG
**Where:** [Audit Log](/AuditLog) under Admin Tools (CompanyAdmin and above) **Where:** [Audit Log](/AuditLog) under Admin Tools (CompanyAdmin and above)
@@ -1423,75 +1369,5 @@ public static class HelpKnowledgeBase
--- ---
Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center. Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center.
---
**Custom Formula Item Templates (Company Settings Custom Formulas):**
Reusable NCalc pricing formulas for complex fabricated items (roof curbs, electrical enclosures, welded frames). Each template has a list of measurement fields and a formula expression. Two output modes:
- Fixed Rate: formula produces a dollar amount stored as ManualUnitPrice × Qty
- Surface Area: formula produces sq ft standard coating engine prices it
Creating a template: New Template enter name + output mode + fields (name, label, unit, default value) write NCalc formula using field names Run to test optionally upload a diagram image Save
AI Generator: enter description in "AI Formula Generator" box in the template editor Claude suggests formula + fields + mode review and save
Using in wizard: item wizard shows "Custom Formula Item" card if active templates exist choose template template diagram shown for reference enter measurements Calculate verify result continue to coatings/prep steps
Formula variable names: snake_case, letters/digits/underscores only. Reserved variable: "rate" (pre-populated from Default Rate).
NCalc syntax: +, -, *, /, %, Pow(b,e), Abs(x), Round(x,d), Max(a,b), Min(a,b), Sqrt(x)
Common formula patterns (all Fixed Rate, divide inches by 144 to get sqft):
- 6-sided box: fields l_in/w_in/h_in 2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate
- Cylinder: fields d_in/h_in (3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate
- Flat panel: fields l_in/w_in l_in * w_in / 144 * rate
Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button
Help article: Help Custom Formula Item Templates
**Community Formula Library (Company Settings Custom Formulas Community Library button):**
Platform-wide library where companies share their custom formula templates with all Powder Coating Logix users.
- Sharing: in the Library column on the Custom Formulas tab, click Share add optional Tags and Industry Hint Publish to Library. Eligible templates: ones created from scratch, or imported templates the company has since modified. Unmodified copies of another company's formula cannot be re-shared.
- Browsing: open via Community Library button on Custom Formulas tab search by name/description/tags, filter by Output Mode or Industry click Preview & Import to see full fields, formula expression, and diagram.
- Importing: click Import to My Formulas in the preview modal a fully independent copy is added to your local library; edits to the copy do not affect the original. If the original creator deletes their diagram image, the image is automatically cleared from all imported copies.
- Attribution: every card shows the source company name. If a company imports a formula, modifies it, and re-shares it, the card displays "Inspired by [original name] from [original company]".
- Your own shared formulas: appear in the library with a gold "Your Formula" badge; Manage button links back to Company Settings. To remove from the library, click Unshare in the Library column existing imports are unaffected.
- Import counts are shown on each card.
- Rating: each card from another company has thumbs-up / thumbs-down buttons labelled "Rate:". One vote per company per formula; clicking the same button again removes the vote; clicking the opposite button switches it. Cannot rate your own formulas. Library sorts by net rating (thumbs up minus thumbs down) then by import count.
---
**Employee Timeclock (/Timeclock):**
Facility-level clock in/out for employees tracks when workers arrive and leave, separate from Job Time Entries which log hours against a specific job.
Each punch creates a segment with a type: Work, Break, or Lunch. Only Work segments count toward paid-hours totals Break and Lunch time is recorded for reference but excluded from all day/week totals and the payroll CSV.
Segment types (ClockEntryType enum): Work=0 (default), Break=1, Lunch=2.
Dashboard (/Timeclock):
- My Clock Status card: shows current state with color-coded badge green "Working", yellow "On Break", blue "At Lunch", or grey "Clocked Out"
- Clock In button: appears when clocked out; starts a new Work segment
- Clock Out button: appears when Working; ends the current Work segment (end of day)
- Break button: appears when Working AND company has "Allow Multiple Punches Per Day" enabled; closes the Work segment and opens a Break segment
- Lunch button: same as Break but opens a Lunch segment
- Return to Work button: appears when on break or lunch; closes the break/lunch segment and opens a new Work segment
- Who's In table: all employees with an open segment, showing their status badge (Working / On Break / At Lunch) and elapsed time; refreshes every 60 seconds
- My Recent History: last 14 days of your own punch segments grouped by day; each row shows a type badge (Work/Break/Lunch); daily totals count Work only
Manager tools (CompanyAdmin and Manager roles):
- Edit entry: pencil icon on any entry in My Recent History or Team History adjust clock-in, clock-out, notes; hours recalculated on save
- Delete entry: trash icon; soft-deletes the entry
- Manual Entry button (in Team History card header): opens a modal to create a time entry for any active employee in the company; fields: employee dropdown, clock-in datetime, optional clock-out datetime, optional notes; manual entries are always Work type
- Team History: date-range loader showing all employees' segments with type badges and edit/delete controls
Kiosk mode (/Timeclock/Kiosk):
A shared shop tablet page that does not require login. Employees tap their name tile enter 4-digit PIN system automatically clocks in (if out) or clocks out (if in). Confirmation screen shows time, segment hours, and today's running total. Returns to employee grid after 4 seconds. Kiosk performs a simple toggle only no Break/Lunch buttons on the kiosk; use the main dashboard for break tracking.
Activation: Manager navigates to /Timeclock/Kiosk/Activate generates device cookie; kiosk then runs without login. Multiple tablets per company supported, each with its own token. Deactivate from Settings Company Timeclock tab.
PIN management: on a user's Edit page (Settings Users Edit User) there is a "Timeclock Kiosk PIN" section. Enter a 4-digit PIN and click "Set PIN" to enable that employee on the kiosk. Click "Clear" to disable them. Only employees with a PIN appear on the kiosk grid.
Attendance Report (/Reports/Attendance):
- Defaults to the current ISO week; toggle between Week and Month view using the mode buttons
- Week view: dropdown of the last 12 weeks (labelled "This Week", "Last Week", then date ranges); weekly subtotal rows hidden (redundant for a single-week view)
- Month view: dropdown of the last 12 months; weekly subtotal rows shown inside each employee card
- Dropdown auto-applies on change no separate submit button needed
- Each segment row has a type badge (Work/Break/Lunch); Break and Lunch hours shown in muted text and excluded from all totals
- Employee total badge and day totals count Work segments only
CSV Export: "Export CSV (Payroll)" downloads a flat file for the selected period one row per segment; columns: Employee Name, Date, Day of Week, Type, Clock In, Clock Out, Segment Hours, Day Total Hours (Work rows only), Week Total Hours (Work rows only), Notes. Filename includes the period label.
Help article: Help Timeclock
"""; """;
} }
@@ -93,11 +93,9 @@ public class SubscriptionMiddleware
// SuperAdmins get all features but still need the feature flags set for views // SuperAdmins get all features but still need the feature flags set for views
if (context.User.IsInRole(AppConstants.Roles.SuperAdmin)) if (context.User.IsInRole(AppConstants.Roles.SuperAdmin))
{ {
context.Items["AllowOnlinePayments"] = true; context.Items["AllowOnlinePayments"] = true;
context.Items["AllowAccounting"] = true; context.Items["AllowAccounting"] = true;
context.Items["AllowSms"] = true; context.Items["AllowSms"] = true;
context.Items["AllowCustomFormulas"] = true;
context.Items["TimeclockEnabled"] = true;
await _next(context); await _next(context);
return; return;
} }
@@ -145,11 +143,8 @@ public class SubscriptionMiddleware
context.Items["AllowAccounting"] = company.IsComped context.Items["AllowAccounting"] = company.IsComped
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false)); || (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in // SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
context.Items["AllowSms"] = company.IsComped context.Items["AllowSms"] = company.IsComped
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled); || (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
context.Items["AllowCustomFormulas"] = company.IsComped
|| (planConfig?.AllowCustomFormulas ?? false);
context.Items["TimeclockEnabled"] = company.TimeclockEnabled;
if (company.IsComped) if (company.IsComped)
{ {
+3 -63
View File
@@ -180,18 +180,6 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddDefaultUI() .AddDefaultUI()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>(); .AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session
// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless
// of whether the user checked "Remember me". SlidingExpiration renews the window on each request.
builder.Services.ConfigureApplicationCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
options.Cookie.MaxAge = TimeSpan.FromDays(30);
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
});
// Register HttpContextAccessor for multi-tenancy // Register HttpContextAccessor for multi-tenancy
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -219,8 +207,6 @@ builder.Services.AddScoped<IOperationalReportService, OperationalReportService>(
builder.Services.AddScoped<IAiHelpService, AiHelpService>(); builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>(); builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(); builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>(); builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>(); builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
@@ -294,8 +280,6 @@ cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new AccountingProfile()); cfg.AddProfile(new AccountingProfile());
cfg.AddProfile(new PurchaseOrderProfile()); cfg.AddProfile(new PurchaseOrderProfile());
cfg.AddProfile(new PricingTierProfile()); cfg.AddProfile(new PricingTierProfile());
cfg.AddProfile(new CustomItemTemplateProfile());
cfg.AddProfile(new FormulaLibraryProfile());
}, loggerFactory); }, loggerFactory);
return config.CreateMapper(); return config.CreateMapper();
}); });
@@ -546,49 +530,6 @@ builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to
// download the empty response as a file. Without this, Safari shows "Login / data Zero KB".
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest"
|| (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json"));
if (isAjax)
{
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
await context.HttpContext.Response.WriteAsync(
"""{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct);
}
else
{
context.HttpContext.Response.ContentType = "text/html; charset=utf-8";
await context.HttpContext.Response.WriteAsync("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too Many Requests &mdash; Powder Coating Logix</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
</style>
</head>
<body>
<div class="card">
<h2>Too Many Requests</h2>
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
<a href="/Identity/Account/Login">Back to Login</a>
</div>
</body>
</html>
""", ct);
}
};
// login / password-reset — 10 per minute per IP // login / password-reset — 10 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx => options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
RateLimitPartition.GetSlidingWindowLimiter( RateLimitPartition.GetSlidingWindowLimiter(
@@ -675,8 +616,8 @@ System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
// SECURITY: Add security headers middleware // SECURITY: Add security headers middleware
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
// Prevent clickjacking — SAMEORIGIN so our own iframe embeds (QR labels, etc.) still work // Prevent clickjacking
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN"); context.Response.Headers.Append("X-Frame-Options", "DENY");
// Prevent MIME type sniffing // Prevent MIME type sniffing
context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
@@ -703,8 +644,7 @@ app.Use(async (context, next) =>
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:; " + "img-src 'self' data: https:; " +
$"connect-src {cspConnectSrc}; " + $"connect-src {cspConnectSrc}; " +
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " + "frame-src https://js.stripe.com https://hooks.stripe.com");
"frame-ancestors 'self'");
// Referrer Policy - control referrer information // Referrer Policy - control referrer information
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -1,61 +0,0 @@
namespace PowderCoating.Web.ViewModels.Reports;
public class JobProfitabilityViewModel : ReportViewModelBase
{
public bool TimeTrackedOnly { get; set; }
// ── Summary KPIs ──────────────────────────────────────────────────────
public int TotalJobs { get; set; }
public int JobsWithTimeEntries { get; set; }
public decimal TotalRevenue { get; set; }
public decimal TotalCollected { get; set; }
public decimal TotalEstimatedCost { get; set; }
public decimal TotalActualHours { get; set; }
/// <summary>Average margin % across jobs that have at least some cost data.</summary>
public decimal AvgMarginPercent { get; set; }
// ── Detail rows ───────────────────────────────────────────────────────
public List<JobProfitabilityItem> Items { get; set; } = new();
}
public class JobProfitabilityItem
{
public int JobId { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string StatusName { get; set; } = string.Empty;
public string StatusColorClass { get; set; } = "bg-secondary";
public DateTime JobDate { get; set; }
/// <summary>The invoiced / final price of the job.</summary>
public decimal FinalPrice { get; set; }
/// <summary>How much has actually been collected on the linked invoice.</summary>
public decimal AmountCollected { get; set; }
/// <summary>Total hours logged via time entries.</summary>
public decimal ActualHours { get; set; }
/// <summary>ActualHours × StandardLaborRate.</summary>
public decimal ActualLaborCost { get; set; }
/// <summary>
/// Sum of (ActualPowderUsedLbs ?? PowderToOrder) × PowderCostPerLb across all coats.
/// Zero when no coat has pricing data.
/// </summary>
public decimal ActualPowderCost { get; set; }
public decimal EstimatedTotalCost => ActualLaborCost + ActualPowderCost;
public decimal GrossMargin => FinalPrice - EstimatedTotalCost;
public decimal MarginPercent => FinalPrice > 0
? Math.Round(GrossMargin / FinalPrice * 100, 1)
: 0;
/// <summary>True when at least one JobTimeEntry exists for this job.</summary>
public bool HasTimeEntries { get; set; }
}
@@ -1,8 +1,8 @@
@model PowderCoating.Application.DTOs.Accounting.AccountLedgerDto @model PowderCoating.Application.DTOs.Accounting.AccountLedgerDto
@using PowderCoating.Core.Enums @using PowderCoating.Core.Enums
@{ @{
ViewData["Title"] = $"Ledger - {Model.AccountNumber} {Model.Name}"; ViewData["Title"] = $"Ledger &mdash; {Model.AccountNumber} {Model.Name}";
ViewData["PageIcon"] = "bi-journal-text"; ViewData["PageIcon"] = "bi-journal-text";
ViewData["PageHelpTitle"] = "Account Ledger"; ViewData["PageHelpTitle"] = "Account Ledger";
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view."; ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
@@ -99,7 +99,7 @@
<tr> <tr>
<td class="fw-bold">@c.ClosedYear</td> <td class="fw-bold">@c.ClosedYear</td>
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td> <td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
<td>@Html.Raw(c.ClosedBy ?? "&mdash;")</td> <td>@(c.ClosedBy ?? "&mdash;")</td>
<td> <td>
@if (c.JournalEntry != null) @if (c.JournalEntry != null)
{ {
@@ -207,16 +207,16 @@
</span> </span>
</td> </td>
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Today > 0 ? row.Today.ToString("N0") : "&mdash;") @(row.Today > 0 ? row.Today.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "&mdash;") @(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")"> <td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@Html.Raw(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "&mdash;") @(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")"> <td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
@Html.Raw(row.AllTime > 0 ? row.AllTime.ToString("N0") : "&mdash;") @(row.AllTime > 0 ? row.AllTime.ToString("N0") : "&mdash;")
</td> </td>
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")"> <td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
@if (row.PhotoCount > 0) @if (row.PhotoCount > 0)
@@ -46,19 +46,19 @@
<dd class="col-7">@Model.EntityType</dd> <dd class="col-7">@Model.EntityType</dd>
<dt class="col-5 text-muted">Entity ID</dt> <dt class="col-5 text-muted">Entity ID</dt>
<dd class="col-7">@Html.Raw(Model.EntityId ?? "&mdash;")</dd> <dd class="col-7">@(Model.EntityId ?? "&mdash;")</dd>
<dt class="col-5 text-muted">Description</dt> <dt class="col-5 text-muted">Description</dt>
<dd class="col-7">@Html.Raw(Model.EntityDescription ?? "&mdash;")</dd> <dd class="col-7">@(Model.EntityDescription ?? "&mdash;")</dd>
<dt class="col-5 text-muted">User</dt> <dt class="col-5 text-muted">User</dt>
<dd class="col-7">@Model.UserName</dd> <dd class="col-7">@Model.UserName</dd>
<dt class="col-5 text-muted">Company</dt> <dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@Html.Raw(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "&mdash;"))</dd> <dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "&mdash;"))</dd>
<dt class="col-5 text-muted">IP Address</dt> <dt class="col-5 text-muted">IP Address</dt>
<dd class="col-7">@Html.Raw(Model.IpAddress ?? "&mdash;")</dd> <dd class="col-7">@(Model.IpAddress ?? "&mdash;")</dd>
</dl> </dl>
</div> </div>
</div> </div>
@@ -95,8 +95,8 @@
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null; var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
<tr> <tr>
<td class="fw-medium">@key</td> <td class="fw-medium">@key</td>
<td class="text-danger font-monospace">@Html.Raw(oldVal ?? "&mdash;")</td> <td class="text-danger font-monospace">@(oldVal ?? "&mdash;")</td>
<td class="text-success font-monospace">@Html.Raw(newVal ?? "&mdash;")</td> <td class="text-success font-monospace">@(newVal ?? "&mdash;")</td>
</tr> </tr>
} }
} }
@@ -1,7 +1,7 @@
@model PowderCoating.Core.Entities.BankReconciliation @model PowderCoating.Core.Entities.BankReconciliation
@using PowderCoating.Web.Controllers @using PowderCoating.Web.Controllers
@{ @{
ViewData["Title"] = $"Reconciliation Report - {Model.Account?.Name}"; ViewData["Title"] = $"Reconciliation Report &ndash; {Model.Account?.Name}";
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>(); var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new(); var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
} }

Some files were not shown because too many files have changed in this diff Show More