Compare commits

..

7 Commits

Author SHA1 Message Date
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
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
77 changed files with 335 additions and 11363 deletions
@@ -486,7 +486,6 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty;
@@ -512,11 +511,6 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { 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
@@ -196,9 +196,7 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.ForMember(dest => dest.ReworkJobNumber,
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
.ForMember(dest => dest.ReworkPricingType,
opt => opt.MapFrom(src => src.ReworkPricingType));
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
// Job → JobDto (rework fields)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -31,9 +31,6 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { 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 ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; }
-8
View File
@@ -144,14 +144,6 @@ public enum ReworkResolution
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
{
New = 0,
@@ -92,10 +92,4 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day.
/// </summary>
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);
}
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));
}
}
}
@@ -6711,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6722,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6733,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7990,9 +7990,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId")
.HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType")
.HasColumnType("int");
@@ -187,14 +187,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{
@@ -946,10 +946,7 @@ public class InventoryController : Controller
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)
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
else
@@ -1623,12 +1620,11 @@ public class InventoryController : Controller
/// 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.
/// </summary>
public async Task<IActionResult> Label(int? id, bool embed = false)
public async Task<IActionResult> Label(int? id)
{
if (id == null) return NotFound();
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
if (item == null) return NotFound();
ViewBag.IsEmbed = embed;
return View(_mapper.Map<InventoryItemDto>(item));
}
@@ -2407,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)
if (request.SendEmail && newStatus != null)
{
@@ -3550,13 +3528,10 @@ public class JobsController : Controller
}
/// <summary>
/// Records a rework event against a job. Optionally creates a linked rework job so the
/// repair can flow through the full shop lifecycle. When creating a rework job:
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
/// - Only items selected by the user are copied (partial rework support)
/// - 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
/// Records a rework event against a job item (e.g. defect found during QC).
/// Automatically creates a new linked rework Job so the repair work can be tracked
/// through the same job lifecycle. The rework job inherits the original job's customer,
/// oven, and items so the shop has a complete specification to work from.
/// </summary>
[HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
@@ -3565,207 +3540,95 @@ public class JobsController : Controller
if (job == null) return NotFound();
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
{
ReworkType.InternalDefect => "Internal Defect",
ReworkType.CustomerWarranty => "Customer Warranty",
ReworkType.CustomerDamage => "Customer Damage",
_ => dto.ReworkType.ToString()
};
var reasonLabel = dto.Reason switch
{
ReworkReason.AdhesionFailure => "Adhesion Failure",
ReworkReason.Contamination => "Contamination",
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}.";
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = job.CustomerId,
Description = $"REWORK: {job.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = job.Id,
SpecialInstructions = $"Rework of {job.JobNumber}.",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
} : null;
var currentUserId = _userManager.GetUserId(User);
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
if (reworkJob != null)
{
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
{
JobId = dto.JobId,
JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType,
Reason = dto.Reason,
JobId = dto.JobId,
JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType,
Reason = dto.Reason,
DefectDescription = dto.DefectDescription,
DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName,
DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName,
EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes,
ReworkPricingType = dto.ReworkPricingType,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
BillingNotes = dto.BillingNotes,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.ReworkRecords.AddAsync(record);
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);
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>
/// 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).
@@ -3817,6 +3680,66 @@ public class JobsController : Controller
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 ──────────────────────────────────────────
/// <summary>
@@ -4388,13 +4311,7 @@ public class LogMaterialRequest
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest
{
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 CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest
{
@@ -99,7 +99,7 @@
<tr>
<td class="fw-bold">@c.ClosedYear</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>
@if (c.JournalEntry != null)
{
@@ -207,16 +207,16 @@
</span>
</td>
<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 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 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 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 class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
@if (row.PhotoCount > 0)
@@ -46,19 +46,19 @@
<dd class="col-7">@Model.EntityType</dd>
<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>
<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>
<dd class="col-7">@Model.UserName</dd>
<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>
<dd class="col-7">@Html.Raw(Model.IpAddress ?? "&mdash;")</dd>
<dd class="col-7">@(Model.IpAddress ?? "&mdash;")</dd>
</dl>
</div>
</div>
@@ -95,8 +95,8 @@
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
<tr>
<td class="fw-medium">@key</td>
<td class="text-danger font-monospace">@Html.Raw(oldVal ?? "&mdash;")</td>
<td class="text-success font-monospace">@Html.Raw(newVal ?? "&mdash;")</td>
<td class="text-danger font-monospace">@(oldVal ?? "&mdash;")</td>
<td class="text-success font-monospace">@(newVal ?? "&mdash;")</td>
</tr>
}
}
@@ -248,7 +248,7 @@
{
<tr class="text-muted">
<td><code>@ban.IpAddress</code></td>
<td><small>@Html.Raw(ban.Reason ?? "&mdash;")</small></td>
<td><small>@(ban.Reason ?? "&mdash;")</small></td>
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
<td>
@if (!ban.IsActive)
@@ -162,7 +162,7 @@
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
<td class="text-end">@entry.Total.ToString("C")</td>
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@Html.Raw(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
@@ -148,7 +148,7 @@
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr>
<tr><th>Code</th><td>@Html.Raw(Model.CompanyCode ?? "&mdash;")</td></tr>
<tr><th>Code</th><td>@(Model.CompanyCode ?? "&mdash;")</td></tr>
<tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr>
<tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr>
<tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr>
@@ -174,7 +174,7 @@
<table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr>
<tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr>
<tr><th>Phone</th><td>@Html.Raw(Model.Phone ?? "&mdash;")</td></tr>
<tr><th>Phone</th><td>@(Model.Phone ?? "&mdash;")</td></tr>
</table>
</div>
</div>
@@ -283,7 +283,7 @@
}
else { <span class="text-muted">N/A</span> }
</td>
<td>@Html.Raw(user.Department ?? "&mdash;")</td>
<td>@(user.Department ?? "&mdash;")</td>
<td>
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
@(user.IsActive ? "Active" : "Inactive")
@@ -527,20 +527,20 @@
@{
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
}
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
</tr>
<tr>
<th>First Invoice</th>
<td>@Html.Raw(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
<tr>
<th>Workflow Completed</th>
<td>@Html.Raw(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
<tr>
<th>Widget Dismissed</th>
<td>@Html.Raw(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
</table>
</div>
@@ -181,7 +181,7 @@
</td>
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
<td class="small text-muted">@Html.Raw(a.AppliedBy?.FullName ?? "&mdash;")</td>
<td class="small text-muted">@(a.AppliedBy?.FullName ?? "&mdash;")</td>
</tr>
}
</tbody>
@@ -204,7 +204,7 @@ else
</td>
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
<td class="@(expired ? "text-danger fw-semibold" : "")">
@Html.Raw(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "&mdash;")
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "&mdash;")
@if (expired) { <small>(Expired)</small> }
</td>
<td>
@@ -224,7 +224,7 @@
}
<div class="mobile-card-row">
<span class="mobile-card-label">Phone</span>
<span class="mobile-card-value">@Html.Raw(customer.Phone ?? "&mdash;")</span>
<span class="mobile-card-value">@(customer.Phone ?? "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
@@ -563,7 +563,7 @@
<tr>
<td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td></td>
</tr>
</tfoot>
@@ -680,7 +680,7 @@
<tr>
<td colspan="2">Vendor Total</td>
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
<td class="text-end">@Html.Raw(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "&mdash;")</td>
<td colspan="2"></td>
</tr>
</tfoot>
@@ -139,7 +139,7 @@
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-muted">
@Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input entity-select"
@@ -169,7 +169,7 @@
</div>
<div class="mobile-card-title">
<h6>@s.Label</h6>
<small>Oldest: @Html.Raw(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")</small>
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "&mdash;")</small>
</div>
</div>
<div class="mobile-card-body">
@@ -78,7 +78,7 @@
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
</td>
<td>
<div>@Html.Raw(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "&mdash;" : row.CompanyAdminName)</div>
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "&mdash;" : row.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
{
<div class="small text-muted">@row.CompanyAdminEmail</div>
@@ -75,7 +75,7 @@
<div class="fw-semibold">@company.CompanyName</div>
<div class="small text-muted">#@company.CompanyId</div>
</td>
<td>@Html.Raw(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "&mdash;" : company.PrimaryContactName)</td>
<td>@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "&mdash;" : company.PrimaryContactName)</td>
<td>
@if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail))
{
@@ -87,7 +87,7 @@
}
</td>
<td>
<div>@Html.Raw(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "&mdash;" : company.CompanyAdminName)</div>
<div>@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "&mdash;" : company.CompanyAdminName)</div>
@if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail))
{
<div class="small text-muted">@company.CompanyAdminEmail</div>
@@ -42,7 +42,7 @@
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">@Html.Raw(Model.Location ?? "&mdash;")</p>
<p class="mb-0">@(Model.Location ?? "&mdash;")</p>
</div>
<div class="col-md-6">
<label class="text-muted small mb-1">Status</label>
@@ -76,15 +76,15 @@
<div class="row g-3">
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "&mdash;")</p>
<p class="mb-0">@(Model.Manufacturer ?? "&mdash;")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Model</label>
<p class="mb-0">@Html.Raw(Model.Model ?? "&mdash;")</p>
<p class="mb-0">@(Model.Model ?? "&mdash;")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "&mdash;")</p>
<p class="mb-0">@(Model.SerialNumber ?? "&mdash;")</p>
</div>
</div>
</div>
@@ -94,15 +94,15 @@
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Manufacturer</label>
<p class="mb-0">@Html.Raw(Model.Manufacturer ?? "&mdash;")</p>
<p class="mb-0">@(Model.Manufacturer ?? "&mdash;")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Model</label>
<p class="mb-0">@Html.Raw(Model.Model ?? "&mdash;")</p>
<p class="mb-0">@(Model.Model ?? "&mdash;")</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Serial Number</label>
<p class="mb-0">@Html.Raw(Model.SerialNumber ?? "&mdash;")</p>
<p class="mb-0">@(Model.SerialNumber ?? "&mdash;")</p>
</div>
</div>
</div>
@@ -88,7 +88,7 @@
<td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td>
@Html.Raw(cert.ExpiryDate.HasValue
@(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;")
</td>
+5 -34
View File
@@ -475,41 +475,12 @@
actual hours vs. estimated hours for costing and productivity analysis.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework (also called Redo)</h3>
<h3 class="h6 fw-semibold mt-3 mb-2">Rework</h3>
<p>
If a finished part fails quality inspection or a customer returns it damaged, open the original
job&rsquo;s Details page and use the <strong>Rework Log</strong> section to record it. Rework
and redo mean the same thing throughout the system.
</p>
<p>Each 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 the issue, and pricing responsibility.</p>
<h4 class="h6 fw-semibold mt-3 mb-1">Pricing Responsibility</h4>
<ul>
<li><strong>Shop Fault &mdash; no charge:</strong> All copied item prices are set to $0.</li>
<li><strong>Customer responsible &mdash; reduced rate:</strong> Prices are copied from the original job; edit them down after creation.</li>
<li><strong>Customer responsible &mdash; full price:</strong> Prices are copied as-is.</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Creating a Rework Job</h4>
<p>
Toggle <strong>Parts are back &mdash; create a Rework Job</strong> at the top of the log form.
Select the items that need to be redone and choose the pricing responsibility. The system will:
</p>
<ul>
<li>Create a new job with a sub-number (e.g., <code>JOB-2605-0001-R1</code>)</li>
<li>Copy the selected items with their coats and prep services</li>
<li>Auto-record intake &mdash; parts are already on hand when rework is logged</li>
<li>Set the job description to the defect type, reason, and pricing so it is visible at the top of the job</li>
</ul>
<h4 class="h6 fw-semibold mt-3 mb-1">Automatic Resolution</h4>
<p>
When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework
record on the original job is automatically marked <strong>Resolved</strong> &mdash; no manual
follow-up needed. If the rework job is <strong>Cancelled</strong>, the record is marked
<strong>Written Off</strong> instead.
If finished parts fail quality inspection or need to be re-coated, create a rework record
from the Job Details page. Rework records track the rework type, the reason (adhesion failure,
color mismatch, damage, etc.), and the resolution. This data helps identify recurring quality
issues over time.
</p>
</section>
@@ -5,11 +5,6 @@
ViewData["PageIcon"] = "bi-box-seam";
ViewData["PageHelpTitle"] = "Inventory Item";
ViewData["PageHelpContent"] = "Full detail for this inventory item. Stock Information shows current quantity and reorder thresholds &mdash; a Low Stock banner appears when quantity is at or below the Reorder Point. Pricing shows Unit Cost (what you paid), Average Cost (weighted average across purchases), and Total Stock Value. Use the Actions panel to edit, view jobs using this powder, or delete the item.";
string SafeUrl(string? url) =>
string.IsNullOrEmpty(url) ? "#"
: (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
? url : "http://" + url;
}
@section Styles {
@@ -189,7 +184,7 @@
<div class="col-12">
<label class="text-muted small mb-1">Product URL</label>
<p class="mb-0">
<a href="@SafeUrl(Model.SpecPageUrl)" target="_blank" class="text-decoration-none">
<a href="@Model.SpecPageUrl" target="_blank" class="text-decoration-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View on Manufacturer's Web Site
</a>
</p>
@@ -202,13 +197,13 @@
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(Model.SdsUrl))
{
<a href="@SafeUrl(Model.SdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<a href="@Model.SdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-pdf me-1"></i>Safety Data Sheet
</a>
}
@if (!string.IsNullOrEmpty(Model.TdsUrl))
{
<a href="@SafeUrl(Model.TdsUrl)" target="_blank" class="btn btn-sm btn-outline-secondary">
<a href="@Model.TdsUrl" target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Technical Data Sheet
</a>
}
@@ -298,14 +293,14 @@
{
<div class="col-md-6">
<label class="text-muted small mb-1">Inventory Account</label>
<p class="mb-0">@Html.Raw(Model.InventoryAccountName ?? "&mdash;")</p>
<p class="mb-0">@(Model.InventoryAccountName ?? "&mdash;")</p>
</div>
}
@if (Model.CogsAccountId.HasValue)
{
<div class="col-md-6">
<label class="text-muted small mb-1">COGS Account</label>
<p class="mb-0">@Html.Raw(Model.CogsAccountName ?? "&mdash;")</p>
<p class="mb-0">@(Model.CogsAccountName ?? "&mdash;")</p>
</div>
}
</div>
@@ -380,7 +375,7 @@
</div>
<div class="col-6">
<label class="text-muted small mb-1">Location</label>
<p class="mb-0">@Html.Raw(Model.Location ?? "&mdash;")</p>
<p class="mb-0">@(Model.Location ?? "&mdash;")</p>
</div>
<div class="col-6">
<label class="text-muted small mb-1">Reorder Point</label>
@@ -457,9 +452,9 @@
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#stockAdjustmentModal">
<i class="bi bi-plus-slash-minus me-2"></i>Stock Adjustment
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#qrLabelModal">
<a asp-action="Label" asp-route-id="@Model.Id" target="_blank" class="btn btn-outline-secondary">
<i class="bi bi-qr-code me-2"></i>Print QR Label
</button>
</a>
<a asp-action="Ledger" asp-route-inventoryItemId="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-journal-text me-2"></i>View Activity History
</a>
@@ -644,33 +639,6 @@
</div>
</div>
@* QR Label Modal *@
<div class="modal fade" id="qrLabelModal" tabindex="-1" aria-labelledby="qrLabelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="qrLabelModalLabel">
<i class="bi bi-qr-code me-2"></i>QR Label &mdash; @Model.Name
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex justify-content-center" style="background:#f0f0f0;min-height:360px;">
<iframe id="qrLabelFrame"
src="@Url.Action("Label", new { id = Model.Id, embed = true })"
style="width:100%;height:400px;border:none;"
title="QR Label Preview"></iframe>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-primary btn-sm"
onclick="document.getElementById('qrLabelFrame').contentWindow.print()">
<i class="bi bi-printer me-2"></i>Print Label
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
/* ── Stock Adjustment Modal ───────────────────────────────── */
@@ -22,11 +22,6 @@
min-height: 100vh;
}
body.embedded {
padding-top: 24px;
min-height: auto;
}
.screen-controls {
display: flex;
gap: 10px;
@@ -117,10 +112,8 @@
}
</style>
</head>
<body class="@((bool)(ViewBag.IsEmbed ?? false) ? "embedded" : "")">
<body>
@if (!(bool)(ViewBag.IsEmbed ?? false))
{
<div class="screen-controls">
<button class="btn btn-primary" onclick="window.print()">
&#128438; Print Label
@@ -129,7 +122,6 @@
&#8592; Back to Item
</a>
</div>
}
<div class="label-card">
<div class="label-logo">Powder Coating Logix</div>
@@ -291,7 +291,7 @@
}
</td>
}
<td>@Html.Raw(u.CoatColor ?? "&mdash;")</td>
<td>@(u.CoatColor ?? "&mdash;")</td>
<td class="text-end">@u.EstimatedLbs.ToString("N3")</td>
<td class="text-end fw-semibold">@u.ActualLbsUsed.ToString("N3")</td>
<td class="text-end @(variance > 0 ? "variance-over" : variance < 0 ? "variance-under" : "")">
@@ -81,7 +81,7 @@ else
<tr>
<td style="color:#aaa;font-size:9pt;">@row</td>
<td><strong>@item.Name</strong></td>
<td>@Html.Raw(item.ColorName ?? "&mdash;")</td>
<td>@(item.ColorName ?? "&mdash;")</td>
<td style="font-family:monospace;font-size:9.5pt;">@item.SKU</td>
</tr>
}
@@ -244,9 +244,9 @@
<div class="text-muted small">@item.Name</div>
}
</td>
<td>@Html.Raw(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@Html.Raw(item.Finish ?? "&mdash;")</td>
<td>@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@(item.Finish ?? "&mdash;")</td>
<td>
@if (item.QuantityOnHand > 0)
{
@@ -399,9 +399,9 @@
<div class="text-muted small">@item.Name</div>
}
</td>
<td>@Html.Raw(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@Html.Raw(item.Finish ?? "&mdash;")</td>
<td>@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@(item.ManufacturerPartNumber ?? "&mdash;")</td>
<td>@(item.Finish ?? "&mdash;")</td>
<td class="text-end pe-3">
<button class="btn btn-sm btn-outline-danger me-1 btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="false"
@@ -461,7 +461,7 @@
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Manufacturer ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.ManufacturerPartNumber ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.Finish ?? "")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@Html.Raw(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "&mdash;")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">@(item.QuantityOnHand > 0 ? item.QuantityOnHand.ToString("N2") + " " + item.UnitOfMeasure : "&mdash;")</td>
<td style="border:1px solid #ccc;padding:6px 10px;">&nbsp;</td>
</tr>
}
@@ -179,12 +179,12 @@
<div class="col-md-4">
<label class="text-muted small mb-1">Due Date</label>
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
@Html.Raw(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "&mdash;")
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "&mdash;")
</p>
</div>
<div class="col-md-4">
<label class="text-muted small mb-1">Sent Date</label>
<p class="mb-0">@Html.Raw(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "&mdash;")</p>
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "&mdash;")</p>
</div>
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
{
@@ -350,7 +350,7 @@
</span>
</td>
<td class="text-muted">
@Html.Raw(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "&mdash;")
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "&mdash;")
</td>
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
<td>
@@ -396,7 +396,7 @@
<tr>
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
<td>@p.PaymentMethodDisplay</td>
<td>@Html.Raw(p.Reference ?? "&mdash;")</td>
<td>@(p.Reference ?? "&mdash;")</td>
<td>
@if (!string.IsNullOrEmpty(p.DepositAccountName))
{
@@ -407,7 +407,7 @@
<span class="text-muted">&mdash;</span>
}
</td>
<td>@Html.Raw(p.RecordedByName ?? "&mdash;")</td>
<td>@(p.RecordedByName ?? "&mdash;")</td>
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
<td class="text-end">
@if (!isVoided)
@@ -463,7 +463,7 @@
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
<td>@r.RefundMethodDisplay</td>
<td>@r.Reason</td>
<td>@Html.Raw(r.Reference ?? "&mdash;")</td>
<td>@(r.Reference ?? "&mdash;")</td>
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
<td class="text-nowrap">
@@ -144,7 +144,7 @@
</td>
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
@Html.Raw(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "&mdash;")
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "&mdash;")
</td>
<td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@@ -128,7 +128,7 @@
<td class="text-end">@gross.ToString("C")</td>
<td class="text-end text-muted">@inv.OnlineSurchargeCollected.ToString("C")</td>
<td class="text-end fw-semibold">@net.ToString("C")</td>
<td>@Html.Raw(dateDisplay)</td>
<td>@dateDisplay</td>
<td><span class="badge @statusClass">@inv.OnlinePaymentStatus</span></td>
<td>
@if (!string.IsNullOrEmpty(inv.StripePaymentIntentId))
@@ -206,7 +206,7 @@
}
else
{
@Html.Raw(invNum)
@invNum
}
</td>
<td>@custName</td>
+18 -130
View File
@@ -28,20 +28,6 @@
</div>
</div>
@if (Model.IsReworkJob && Model.OriginalJobId.HasValue)
{
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-3 mb-4">
<i class="bi bi-arrow-repeat fs-5 flex-shrink-0"></i>
<div>
<strong>Rework Job</strong> &mdash; This job was created to redo work from
<a asp-action="Details" asp-route-id="@Model.OriginalJobId" class="alert-link fw-semibold">
@(Model.OriginalJobNumber ?? $"Job #{Model.OriginalJobId}")
</a>.
All costs for this redo are tracked here separately from the original job.
</div>
</div>
}
<!-- Status Banner -->
<div class="alert alert-@Model.StatusColorClass alert-permanent d-flex align-items-center mb-4">
<i class="bi bi-info-circle me-2" style="font-size: 1.5rem;"></i>
@@ -2203,88 +2189,6 @@
<div class="modal-body">
<div id="reworkAddForm">
<div class="row g-3">
<!-- Step 1: Are parts back in the shop? -->
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="rwCreateJobToggle" onchange="rework.toggleCreateJob(this.checked)" />
<label class="form-check-label fw-semibold" for="rwCreateJobToggle">
<i class="bi bi-briefcase me-1"></i>Parts are back &mdash; create a Rework Job in the shop
</label>
<div class="text-muted small">Turn this on if the parts are physically in the shop and need to go back through the workflow.</div>
</div>
</div>
<!-- Item selection: checkboxes when creating a job, single dropdown otherwise -->
<div id="rwCreateJobOptions" style="display:none;" class="col-12">
<div class="border rounded p-3 bg-light">
<div class="mb-3">
<label class="form-label fw-semibold mb-1">Which items need to be redone? <span class="text-danger">*</span></label>
<div class="text-muted small mb-2">Only checked items will be copied to the rework job.</div>
<div id="rwItemCheckboxes">
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<div class="form-check">
<input class="form-check-input rw-item-cb" type="checkbox" value="@item.Id" id="rwItem_@item.Id" />
<label class="form-check-label" for="rwItem_@item.Id">@item.Description</label>
</div>
}
}
</div>
</div>
<div>
<label class="form-label fw-semibold mb-1">Who is responsible? <span class="text-danger">*</span></label>
<div class="row g-2">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingShopFault" value="0" />
<label class="form-check-label" for="rwPricingShopFault">
<strong>Shop Fault</strong>
<span class="text-muted small d-block">Our mistake &mdash; rework job priced at $0.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerReduced" value="1" />
<label class="form-check-label" for="rwPricingCustomerReduced">
<strong>Customer &mdash; Reduced Rate</strong>
<span class="text-muted small d-block">Customer caused it but we&rsquo;re helping out &mdash; prices copied, edit after creation.</span>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="radio" name="rwPricingType" id="rwPricingCustomerFull" value="2" />
<label class="form-check-label" for="rwPricingCustomerFull">
<strong>Customer &mdash; Full Price</strong>
<span class="text-muted small d-block">Customer caused it &mdash; original pricing applies.</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="rwSpecificItemRow" class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<hr class="my-0" />
<div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="rwType">
@@ -2311,6 +2215,19 @@
<label class="form-label">Defect Description <span class="text-danger">*</span></label>
<textarea class="form-control" id="rwDefect" rows="2" placeholder="Describe the defect or issue..."></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
{
<option value="@item.Id">@item.Description</option>
}
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Discovered By</label>
<select class="form-select" id="rwDiscoveredBy">
@@ -2737,20 +2654,9 @@
document.getElementById('rwBillingNotes').value = '';
document.getElementById('rwReportedBy').value = '';
document.getElementById('rwDiscoveredDate').value = new Date().toISOString().split('T')[0];
// Reset rework job creation section
document.getElementById('rwCreateJobToggle').checked = false;
document.getElementById('rwCreateJobOptions').style.display = 'none';
document.querySelectorAll('.rw-item-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('input[name="rwPricingType"]').forEach(r => r.checked = false);
modal.show();
}
function toggleCreateJob(on) {
document.getElementById('rwCreateJobOptions').style.display = on ? '' : 'none';
document.getElementById('rwSpecificItemRow').style.display = on ? 'none' : '';
if (!on) document.getElementById('rwJobItem').value = '';
}
function openEdit(id) {
editId = id;
const r = records.find(x => x.id === id);
@@ -2779,23 +2685,9 @@
// Create
const defect = document.getElementById('rwDefect').value.trim();
if (!defect) { alert('Defect description is required.'); return; }
const createJob = document.getElementById('rwCreateJobToggle').checked;
const selectedItemIds = createJob
? Array.from(document.querySelectorAll('.rw-item-cb:checked')).map(cb => parseInt(cb.value))
: null;
const pricingRadio = document.querySelector('input[name="rwPricingType"]:checked');
if (createJob && (!selectedItemIds || selectedItemIds.length === 0)) {
alert('Select at least one item to include in the rework job.'); return;
}
if (createJob && !pricingRadio) {
alert('Select who is responsible for this rework.'); return;
}
const dto = {
jobId: jid,
jobItemId: createJob ? null : (document.getElementById('rwJobItem').value || null),
jobItemId: document.getElementById('rwJobItem').value || null,
reworkType: parseInt(document.getElementById('rwType').value),
reason: parseInt(document.getElementById('rwReason').value),
defectDescription: defect,
@@ -2804,10 +2696,7 @@
reportedByName: document.getElementById('rwReportedBy').value || null,
estimatedReworkCost: parseFloat(document.getElementById('rwEstCost').value) || 0,
isBillableToCustomer: document.getElementById('rwBillable').checked,
billingNotes: document.getElementById('rwBillingNotes').value || null,
createReworkJob: createJob,
reworkJobItemIds: selectedItemIds,
reworkPricingType: pricingRadio ? parseInt(pricingRadio.value) : null
billingNotes: document.getElementById('rwBillingNotes').value || null
};
const resp = await fetch('/Jobs/AddReworkRecord', {
method: 'POST',
@@ -2852,7 +2741,7 @@
}
load();
return { load, openAdd, openEdit, save, del, toggleCreateJob };
return { load, openAdd, openEdit, save, del };
})();
</script>
@@ -3017,8 +2906,8 @@
function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '&mdash;';
document.getElementById('totalHoursDisplay').innerHTML = fmt;
document.getElementById('timeEntriesTotalHours').innerHTML = total > 0 ? total.toFixed(2) : '&mdash;';
document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '&mdash;';
}
// -- Modal helpers -------------------------------------------------
@@ -3356,4 +3245,3 @@
</div>
</div>
</div>
@@ -483,10 +483,10 @@
<tr>
<td style="text-align: center;">@coat.Sequence</td>
<td><strong>@coat.CoatName</strong></td>
<td>@Html.Raw(coat.ColorName ?? "&mdash;")</td>
<td>@Html.Raw(coat.ColorCode ?? "&mdash;")</td>
<td>@Html.Raw(coat.Finish ?? "&mdash;")</td>
<td>@Html.Raw(coat.VendorName ?? "&mdash;")</td>
<td>@(coat.ColorName ?? "&mdash;")</td>
<td>@(coat.ColorCode ?? "&mdash;")</td>
<td>@(coat.Finish ?? "&mdash;")</td>
<td>@(coat.VendorName ?? "&mdash;")</td>
<td>
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
@@ -561,7 +561,7 @@
<tr onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'"
style="cursor: pointer;">
<td>
<strong>@Html.Raw(item.Equipment?.EquipmentName ?? "&mdash;")</strong>
<strong>@(item.Equipment?.EquipmentName ?? "&mdash;")</strong>
@if (!string.IsNullOrEmpty(item.Equipment?.Location))
{
<br /><small class="text-muted"><i class="bi bi-geo-alt me-1"></i>@item.Equipment.Location</small>
@@ -119,7 +119,7 @@
<dt class="col-5 text-muted">Posted</dt>
<dd class="col-7 small">
@Model.PostedAt.Value.ToLocalTime().ToString("MMM d, yyyy h:mm tt")<br />
<span class="text-muted">by @Html.Raw(Model.PostedBy ?? "&mdash;")</span>
<span class="text-muted">by @(Model.PostedBy ?? "&mdash;")</span>
</dd>
}
<dt class="col-5 text-muted">Created</dt>
@@ -188,16 +188,16 @@
@{
var firstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
}
@Html.Raw(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
<td class="text-muted small">
@Html.Raw(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
@(row.FirstInvoiceCreatedAt.HasValue ? row.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
<td class="text-muted small">
@Html.Raw(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
@(row.FirstWorkflowCompletedAt.HasValue ? row.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
<td class="text-muted small">
@Html.Raw(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
@(row.GuidedActivationDismissedAt.HasValue ? row.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
<td class="text-center">
@switch (row.Status)
@@ -23,7 +23,7 @@
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@Html.Raw(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "&mdash;"))</dd>
<dd class="col-7">@(ViewBag.CompanyName ?? (Model.CompanyId > 0 ? $"#{Model.CompanyId}" : "&mdash;"))</dd>
<dt class="col-5 text-muted">Type</dt>
<dd class="col-7">@Model.NotificationType</dd>
<dt class="col-5 text-muted">Channel</dt>
@@ -188,13 +188,13 @@
<tr>
<td class="text-muted">Monthly ID</td>
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdMonthly">
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "&mdash;" : plan.StripePriceIdMonthly)
@(string.IsNullOrEmpty(plan.StripePriceIdMonthly) ? "&mdash;" : plan.StripePriceIdMonthly)
</td>
</tr>
<tr>
<td class="text-muted">Annual ID</td>
<td class="font-monospace small text-truncate" style="max-width: 120px;" title="@plan.StripePriceIdAnnual">
@Html.Raw(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "&mdash;" : plan.StripePriceIdAnnual)
@(string.IsNullOrEmpty(plan.StripePriceIdAnnual) ? "&mdash;" : plan.StripePriceIdAnnual)
</td>
</tr>
</tbody>
@@ -115,7 +115,7 @@
<td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td>
<td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td>
<td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")">
@Html.Raw(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "&mdash;")
@(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "&mdash;")
</td>
<td class="text-center">@item.ActiveJobCount</td>
<td class="text-center">
@@ -344,7 +344,7 @@
<br /><small class="text-muted">@w.CoatName</small>
</td>
<td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td>
<td>@Html.Raw(w.Complexity ?? "&mdash;")</td>
<td>@(w.Complexity ?? "&mdash;")</td>
<td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td>
<td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td>
<td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td>
@@ -28,7 +28,7 @@ else
<span class="text-muted"> · @coat.ColorName</span>
}
</td>
<td class="text-end small">@Html.Raw(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "&mdash;")</td>
<td class="text-end small">@(coat.EstimatedLbs.HasValue ? $"{coat.EstimatedLbs:0.##}" : "&mdash;")</td>
<td class="text-end small">
@if (coat.IsRecorded)
{
@@ -61,10 +61,10 @@ else
<td>Total</td>
<td class="text-end">@Model.TotalEstimatedLbs.ToString("0.##") lbs</td>
<td class="text-end @(Model.TotalActualLbs > 0 ? "text-success" : "")">
@Html.Raw(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "&mdash;")
@(Model.TotalActualLbs > 0 ? $"{Model.TotalActualLbs:0.##} lbs" : "&mdash;")
</td>
<td class="text-end @(Model.TotalVarianceLbs > 0 ? "text-danger" : Model.TotalVarianceLbs < 0 ? "text-success" : "")">
@Html.Raw(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "&mdash;")
@(Model.TotalActualLbs > 0 ? $"{(Model.TotalVarianceLbs > 0 ? "+" : "")}{Model.TotalVarianceLbs:0.##} lbs" : "&mdash;")
</td>
</tr>
</tfoot>
@@ -93,7 +93,7 @@
<strong>@tier.TierName</strong>
</td>
<td>
<span class="text-muted small">@Html.Raw(tier.Description ?? "&mdash;")</span>
<span class="text-muted small">@(tier.Description ?? "&mdash;")</span>
</td>
<td class="text-center">
@if (tier.DiscountPercent == 0)
@@ -256,7 +256,7 @@
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Expected Delivery</span>
<span class="@(Model.IsOverdue ? "text-danger fw-semibold" : "")">
@Html.Raw(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")
@(Model.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")
</span>
</div>
@if (Model.ReceivedDate.HasValue)
@@ -260,7 +260,7 @@
</span>
</td>
<td>@po.OrderDate.ToString("MM/dd/yyyy")</td>
<td>@Html.Raw(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td>@(po.ExpectedDeliveryDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-center">@po.ItemCount</td>
<td class="text-end fw-semibold">$@po.TotalAmount.ToString("N2")</td>
<td class="text-end">
@@ -156,11 +156,11 @@ else
</a>
<span class="badge bg-secondary ms-1">@vend.Bills.Count bill@(vend.Bills.Count == 1 ? "" : "s")</span>
</td>
<td class="text-end aging-current">@Html.Raw(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@Html.Raw(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@Html.Raw(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@Html.Raw(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@Html.Raw(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-current">@(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@vend.TotalBalance.ToString("C")</td>
</tr>
}
@@ -218,7 +218,7 @@ else
</a>
</td>
<td class="text-muted small">@bill.BillDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@Html.Raw(bill.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-muted small">@(bill.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-end fw-semibold @(bill.DaysOverdue > 30 ? "text-danger" : "")">@bill.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td>
@@ -156,11 +156,11 @@ else
</a>
<span class="badge bg-secondary ms-1">@cust.Invoices.Count inv.</span>
</td>
<td class="text-end aging-current">@Html.Raw(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@Html.Raw(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@Html.Raw(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@Html.Raw(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@Html.Raw(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-current">@(cust.TotalCurrent > 0 ? cust.TotalCurrent.ToString("C") : "&mdash;")</td>
<td class="text-end aging-1-30">@(cust.Total1to30 > 0 ? cust.Total1to30.ToString("C") : "&mdash;")</td>
<td class="text-end aging-31-60">@(cust.Total31to60 > 0 ? cust.Total31to60.ToString("C") : "&mdash;")</td>
<td class="text-end aging-61-90">@(cust.Total61to90 > 0 ? cust.Total61to90.ToString("C") : "&mdash;")</td>
<td class="text-end aging-over90">@(cust.TotalOver90 > 0 ? cust.TotalOver90.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@cust.TotalBalance.ToString("C")</td>
</tr>
}
@@ -218,7 +218,7 @@ else
</a>
</td>
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@Html.Raw(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-end fw-semibold @(inv.DaysOverdue > 30 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
<td class="text-end"><span class="badge @ageBadge">@ageLabel</span></td>
<td></td>
@@ -101,11 +101,11 @@
@item.CustomerName
</a>
</td>
<td class="small">@Html.Raw(item.Email ?? "&mdash;")</td>
<td class="small">@Html.Raw(item.Phone ?? "&mdash;")</td>
<td class="small">@(item.Email ?? "&mdash;")</td>
<td class="small">@(item.Phone ?? "&mdash;")</td>
<td class="text-end">@item.TotalJobs</td>
<td class="text-end">@item.LifetimeRevenue.ToString("C")</td>
<td>@Html.Raw(item.LastJobDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td>@(item.LastJobDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-end">
@if (item.DaysSinceLastJob < 0)
{
@@ -1604,7 +1604,7 @@
<div class="fw-medium">@color.DisplayLabel</div>
<div class="text-muted small">@color.SKU</div>
</td>
<td class="text-muted small">@Html.Raw(color.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@(color.Manufacturer ?? "&mdash;")</td>
<td class="text-end">
<div>@color.TotalLbsUsed.ToString("N1") lbs</div>
<div class="progress mt-1" style="height:4px; min-width:80px;">
@@ -1749,7 +1749,7 @@
@c.BalanceDue.ToString("C")
</td>
<td class="text-end text-muted">@c.AvgInvoiceValue.ToString("C")</td>
<td class="text-muted small">@Html.Raw(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-muted small">@(c.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="pe-3">
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height:6px;">
@@ -1896,10 +1896,10 @@
<span class="badge @badgeClass">@r.RetentionStatus</span>
</td>
<td class="text-center small">
@Html.Raw(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "&mdash;")
@(r.LastJobDate.HasValue ? r.LastJobDate.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
<td class="text-center small">
@Html.Raw(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "&mdash;")
@(r.DaysSinceLastJob >= 0 ? r.DaysSinceLastJob + "d" : "&mdash;")
</td>
<td class="text-end">@r.TotalJobs</td>
<td class="text-end pe-3 fw-semibold">@r.LifetimeRevenue.ToString("C0")</td>
@@ -2235,7 +2235,7 @@
}
</td>
<td class="small">@inv.InvoiceDate.ToString("MMM d, yyyy")</td>
<td class="small">@Html.Raw(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
<td class="small">@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
<td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
<td class="text-end fw-semibold @(inv.BalanceDue > 0 ? "text-danger" : "")">@inv.BalanceDue.ToString("C")</td>
@@ -2349,7 +2349,7 @@
<div class="small text-muted">@p.ColorCode @(!string.IsNullOrEmpty(p.SKU) ? $"· {p.SKU}" : "")</div>
}
</td>
<td class="small text-muted">@Html.Raw(p.Manufacturer ?? "&mdash;")</td>
<td class="small text-muted">@(p.Manufacturer ?? "&mdash;")</td>
<td class="text-end">@p.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@p.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end">
@@ -52,7 +52,7 @@
<div class="small text-muted">@item.SKU</div>
}
</td>
<td>@Html.Raw(item.ColorName ?? "&mdash;")</td>
<td>@(item.ColorName ?? "&mdash;")</td>
<td class="text-end">@item.CurrentStockLbs.ToString("N1")</td>
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
@@ -85,12 +85,12 @@
}
</td>
<td>@item.InvoiceDate.ToString("MMM d, yyyy")</td>
<td>@Html.Raw(item.DueDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td>@(item.DueDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-end">@item.Total.ToString("C")</td>
<td class="text-end text-success">@item.AmountPaid.ToString("C")</td>
<td class="text-end fw-semibold">@item.BalanceDue.ToString("C")</td>
<td class="text-end @bucketClass">
@Html.Raw(item.DaysOverdue > 0 ? item.DaysOverdue.ToString() : "&mdash;")
@(item.DaysOverdue > 0 ? item.DaysOverdue.ToString() : "&mdash;")
</td>
<td><span class="badge @bucketClass bg-opacity-10 border">@item.AgingBucket</span></td>
<td><span class="badge bg-secondary-subtle text-secondary">@item.StatusDisplay</span></td>
@@ -116,7 +116,7 @@
{
<tr class="@(i.QuantityOnHand == 0 ? "table-danger" : "table-warning")">
<td>@i.Name</td>
<td>@Html.Raw(i.ColorName ?? "&mdash;")</td>
<td>@(i.ColorName ?? "&mdash;")</td>
<td class="text-end fw-semibold text-danger">@i.QuantityOnHand.ToString("N1")</td>
<td class="text-end">@i.ReorderPoint.ToString("N1")</td>
<td class="text-muted small">@i.UnitOfMeasure</td>
@@ -60,13 +60,13 @@
}
</td>
<td>
@Html.Raw(item.ColorName ?? "&mdash;")
@(item.ColorName ?? "&mdash;")
@if (!string.IsNullOrEmpty(item.ColorCode))
{
<span class="badge bg-secondary-subtle text-secondary ms-1">@item.ColorCode</span>
}
</td>
<td class="text-muted">@Html.Raw(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted">@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-end">@item.TotalPurchasedLbs.ToString("N1")</td>
<td class="text-end">@item.TotalConsumedLbs.ToString("N1")</td>
<td class="text-end fw-semibold @varianceClass">@item.VarianceLbs.ToString("N1")</td>
@@ -79,8 +79,8 @@
<td>
@item.DisplayLabel
</td>
<td class="text-muted small">@Html.Raw(item.SKU ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(item.Manufacturer ?? "&mdash;")</td>
<td class="text-muted small">@(item.SKU ?? "&mdash;")</td>
<td class="text-muted small">@(item.Manufacturer ?? "&mdash;")</td>
<td class="text-end fw-semibold">@item.TotalLbsUsed.ToString("N1")</td>
<td class="text-end">@item.TotalCost.ToString("C")</td>
<td class="text-end">@item.JobCount</td>
@@ -148,7 +148,7 @@
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
@@ -169,18 +169,18 @@
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-4 fw-semibold">Total COGS</td>
<td class="text-end fw-semibold text-warning">(@Model.TotalCogs.ToString("C"))</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (Model.TotalCogs / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (Model.TotalCogs / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
<tr class="report-subtotal-row">
<td class="ps-2 fw-semibold">Gross Profit</td>
<td class="text-end fw-semibold @(Model.GrossProfit >= 0 ? "text-success" : "text-danger")">@Model.GrossProfit.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : Model.GrossMarginPercent.ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : Model.GrossMarginPercent.ToString("F1") + "%")</td>
</tr>
}
@@ -198,20 +198,20 @@
<tr>
<td class="ps-4">@line.AccountNumber <span class="text-muted">@line.AccountName</span></td>
<td class="text-end">@line.Amount.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (line.Amount / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
}
<tr class="report-subtotal-row">
<td class="ps-4 fw-semibold">Total Expenses</td>
<td class="text-end fw-semibold text-danger">(@Model.TotalExpenses.ToString("C"))</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (Model.TotalExpenses / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (Model.TotalExpenses / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
</tbody>
<tfoot>
<tr class="report-net-row @(Model.NetIncome < 0 ? "report-net-negative" : "")">
<td class="ps-2">Net Income</td>
<td class="text-end @(Model.NetIncome >= 0 ? "text-success" : "text-danger")">@Model.NetIncome.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(Model.TotalRevenue == 0 ? "&mdash;" : (Model.NetIncome / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small">@(Model.TotalRevenue == 0 ? "&mdash;" : (Model.NetIncome / Model.TotalRevenue * 100).ToString("F1") + "%")</td>
</tr>
</tfoot>
</table>
@@ -205,7 +205,7 @@ else
<td class="text-end fw-semibold">@c.TotalInvoiced.ToString("C")</td>
<td class="text-end text-success">@c.TotalPaid.ToString("C")</td>
<td class="text-end @(c.BalanceDue > 0 ? "text-warning" : "text-muted")">@c.BalanceDue.ToString("C")</td>
<td class="text-end text-muted small no-print">@Html.Raw(Model.TotalInvoiced == 0 ? "&mdash;" : (c.TotalInvoiced / Model.TotalInvoiced * 100).ToString("F1") + "%")</td>
<td class="text-end text-muted small no-print">@(Model.TotalInvoiced == 0 ? "&mdash;" : (c.TotalInvoiced / Model.TotalInvoiced * 100).ToString("F1") + "%")</td>
</tr>
}
</tbody>
@@ -263,9 +263,9 @@ else
</td>
<td class="text-muted small">@inv.CustomerName</td>
<td class="text-muted small">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="text-muted small">@Html.Raw(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-muted small">@(inv.DueDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="text-end">@inv.SubTotal.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(inv.TaxAmount > 0 ? inv.TaxAmount.ToString("C") : "&mdash;")</td>
<td class="text-end text-muted small">@(inv.TaxAmount > 0 ? inv.TaxAmount.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
<td class="text-end text-success">@inv.AmountPaid.ToString("C")</td>
<td><span class="badge @statusBadge">@inv.Status</span></td>
@@ -76,7 +76,7 @@
@item.BalanceDue.ToString("C")
</td>
<td class="text-end">@item.AvgInvoiceValue.ToString("C")</td>
<td>@Html.Raw(item.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td>@(item.LastInvoiceDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
</tr>
}
</tbody>
@@ -284,11 +284,11 @@ else
<td class="small text-muted">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td><span class="badge @statusBadge">@inv.Status</span></td>
<td class="text-end">@inv.SubTotal.ToString("C")</td>
<td class="text-end text-muted small">@Html.Raw(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "&mdash;")</td>
<td class="text-end @Html.Raw(isTaxable ? "fw-semibold text-primary" : "text-muted")">@Html.Raw(isTaxable ? inv.TaxAmount.ToString("C") : "&mdash;")</td>
<td class="text-end text-muted small">@(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "&mdash;")</td>
<td class="text-end @(isTaxable ? "fw-semibold text-primary" : "text-muted")">@(isTaxable ? inv.TaxAmount.ToString("C") : "&mdash;")</td>
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
<td class="text-end text-success no-print">@inv.AmountPaid.ToString("C")</td>
<td class="small text-muted">@Html.Raw(string.IsNullOrEmpty(inv.TaxAccountName) ? "&mdash;" : inv.TaxAccountName)</td>
<td class="small text-muted">@(string.IsNullOrEmpty(inv.TaxAccountName) ? "&mdash;" : inv.TaxAccountName)</td>
</tr>
}
</tbody>
@@ -117,7 +117,7 @@ else
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Missing</span>
}
</td>
<td class="small">@Html.Raw(row.Address ?? "&mdash;")</td>
<td class="small">@(row.Address ?? "&mdash;")</td>
<td class="text-end">@row.BillsPaid.ToString("C")</td>
<td class="text-end">@row.ExpensesPaid.ToString("C")</td>
<td class="text-end fw-bold @(row.NeedsForm ? "text-danger" : "")">@row.TotalPaid.ToString("C")</td>
@@ -210,7 +210,7 @@
<td class="small fw-semibold">@alert.CompanyName</td>
<td class="small">@alert.PlanName</td>
<td><span class="badge bg-@statusClass">@alert.Status</span></td>
<td class="small">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="small">@(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</td>
<td class="small @(alert.DaysUntilExpiry < 0 ? "text-danger" : "text-warning")">@daysText</td>
<td>
<a asp-controller="SubscriptionManagement" asp-action="Manage"
@@ -248,7 +248,7 @@
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">End Date</span>
<span class="mobile-card-value">@Html.Raw(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</span>
<span class="mobile-card-value">@(alert.EndDate?.ToString("MM/dd/yyyy") ?? "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Days</span>
@@ -35,7 +35,7 @@
<dd class="col-7"><span class="badge bg-light text-dark border">@Model.EventType</span></dd>
<dt class="col-5 text-muted">Company ID</dt>
<dd class="col-7">@Html.Raw(Model.CompanyId.HasValue ? Model.CompanyId.ToString() : "&mdash;")</dd>
<dd class="col-7">@(Model.CompanyId.HasValue ? Model.CompanyId.ToString() : "&mdash;")</dd>
<dt class="col-5 text-muted">Status</dt>
<dd class="col-7"><span class="badge bg-@statusClass">@Model.Status</span></dd>
@@ -44,7 +44,7 @@
<dd class="col-7">@Model.ReceivedAt.ToString("MM/dd/yyyy HH:mm:ss") UTC</dd>
<dt class="col-5 text-muted">Processed At</dt>
<dd class="col-7">@Html.Raw(Model.ProcessedAt.HasValue ? Model.ProcessedAt.Value.ToString("MM/dd/yyyy HH:mm:ss") + " UTC" : "&mdash;")</dd>
<dd class="col-7">@(Model.ProcessedAt.HasValue ? Model.ProcessedAt.Value.ToString("MM/dd/yyyy HH:mm:ss") + " UTC" : "&mdash;")</dd>
</dl>
</div>
</div>
@@ -145,12 +145,12 @@
<td class="small">
<span class="badge bg-secondary-subtle text-body border">@evt.EventType</span>
</td>
<td class="small">@Html.Raw(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "&mdash;")</td>
<td class="small">@(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "&mdash;")</td>
<td>
<span class="badge bg-@statusClass">@evt.Status</span>
</td>
<td class="small">
@Html.Raw(evt.ProcessedAt.HasValue ? evt.ProcessedAt.Value.ToString("HH:mm:ss") : "&mdash;")
@(evt.ProcessedAt.HasValue ? evt.ProcessedAt.Value.ToString("HH:mm:ss") : "&mdash;")
</td>
<td>
<a asp-action="Details" asp-route-id="@evt.Id" class="btn btn-outline-secondary btn-sm py-0">View</a>
@@ -191,7 +191,7 @@
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Company</span>
<span class="mobile-card-value">@Html.Raw(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "&mdash;")</span>
<span class="mobile-card-value">@(evt.CompanyId.HasValue ? $"#{evt.CompanyId}" : "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Event ID</span>
@@ -66,9 +66,9 @@
<dt class="col-7 text-muted">Users</dt>
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
<dt class="col-7 text-muted">Stripe Customer</dt>
<dd class="col-5"><code class="small">@Html.Raw(Model.StripeCustomerId ?? "&mdash;")</code></dd>
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "&mdash;")</code></dd>
<dt class="col-7 text-muted">Stripe Sub</dt>
<dd class="col-5"><code class="small">@Html.Raw(Model.StripeSubscriptionId ?? "&mdash;")</code></dd>
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "&mdash;")</code></dd>
</dl>
</div>
</div>
@@ -97,7 +97,7 @@
</tr>
<tr>
<th class="ps-4">Last Migration</th>
<td><small class="text-muted font-monospace">@Html.Raw(Model.LastAppliedMigration ?? "&mdash;")</small></td>
<td><small class="text-muted font-monospace">@(Model.LastAppliedMigration ?? "&mdash;")</small></td>
</tr>
<tr>
<th class="ps-4">Last Seed Run</th>
@@ -153,7 +153,7 @@
<td class="text-nowrap small text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</td>
<td><span class="badge @LevelBadge(row.Level)">@row.Level</span></td>
<td class="small text-truncate" style="max-width:180px" title="@row.SourceContext">
@Html.Raw(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")
@(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")
</td>
<td class="small text-truncate" style="max-width:400px">
@row.Message
@@ -162,8 +162,8 @@
<i class="bi bi-bug text-danger ms-1" title="Has exception"></i>
}
</td>
<td class="small text-muted">@Html.Raw(row.UserName ?? "&mdash;")</td>
<td class="small text-muted text-center">@Html.Raw(row.CompanyId?.ToString() ?? "&mdash;")</td>
<td class="small text-muted">@(row.UserName ?? "&mdash;")</td>
<td class="small text-muted text-center">@(row.CompanyId?.ToString() ?? "&mdash;")</td>
<td class="text-center"><i class="bi bi-zoom-in text-muted small"></i></td>
</tr>
}
@@ -195,7 +195,7 @@
<div class="mobile-card-header">
<div class="mobile-card-icon @cardIconBg"><i class="bi @cardIcon"></i></div>
<div class="mobile-card-title">
<h6>@Html.Raw(msgTruncated)</h6>
<h6>@msgTruncated</h6>
<small class="text-muted">@row.Timestamp.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm:ss")</small>
</div>
</div>
@@ -208,7 +208,7 @@
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Source</span>
<span class="mobile-card-value small">@Html.Raw(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")</span>
<span class="mobile-card-value small">@(row.SourceContext?.Split('.').LastOrDefault() ?? "&mdash;")</span>
</div>
@if (row.UserName != null)
{
@@ -67,8 +67,8 @@
}
</td>
<td>@rate.Rate.ToString("0.##")%</td>
<td>@Html.Raw(rate.State ?? "&mdash;")</td>
<td class="text-muted small">@Html.Raw(rate.Description ?? "&mdash;")</td>
<td>@(rate.State ?? "&mdash;")</td>
<td class="text-muted small">@(rate.Description ?? "&mdash;")</td>
<td class="text-center">
@if (rate.IsDefault)
{
@@ -220,23 +220,23 @@
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Users</span>
<span class="mobile-card-value">@row.Users / @Html.Raw(LimitDisplay(row.MaxUsers))</span>
<span class="mobile-card-value">@row.Users / @LimitDisplay(row.MaxUsers)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Active Jobs</span>
<span class="mobile-card-value">@row.ActiveJobs / @Html.Raw(LimitDisplay(row.MaxActiveJobs))</span>
<span class="mobile-card-value">@row.ActiveJobs / @LimitDisplay(row.MaxActiveJobs)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Customers</span>
<span class="mobile-card-value">@row.Customers / @Html.Raw(LimitDisplay(row.MaxCustomers))</span>
<span class="mobile-card-value">@row.Customers / @LimitDisplay(row.MaxCustomers)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Active Quotes</span>
<span class="mobile-card-value">@row.ActiveQuotes / @Html.Raw(LimitDisplay(row.MaxActiveQuotes))</span>
<span class="mobile-card-value">@row.ActiveQuotes / @LimitDisplay(row.MaxActiveQuotes)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Catalog Items</span>
<span class="mobile-card-value">@row.CatalogItems / @Html.Raw(LimitDisplay(row.MaxCatalogItems))</span>
<span class="mobile-card-value">@row.CatalogItems / @LimitDisplay(row.MaxCatalogItems)</span>
</div>
</div>
<div class="mobile-card-footer">
@@ -123,13 +123,13 @@
}
</td>
<td>
<span class="path-badge text-muted" title="@u.CurrentPath">@Html.Raw(u.CurrentPath ?? "&mdash;")</span>
<span class="path-badge text-muted" title="@u.CurrentPath">@(u.CurrentPath ?? "&mdash;")</span>
</td>
<td>
<div class="mb-1 small text-muted">@secsAgo s ago</div>
<div class="last-seen-bar" style="width:@barPct%"></div>
</td>
<td class="small text-muted">@Html.Raw(u.IpAddress ?? "&mdash;")</td>
<td class="small text-muted">@(u.IpAddress ?? "&mdash;")</td>
</tr>
}
</tbody>
@@ -154,7 +154,7 @@
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Company</span>
<span class="mobile-card-value">@Html.Raw(u.CompanyName ?? "&mdash;")</span>
<span class="mobile-card-value">@(u.CompanyName ?? "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Role</span>
@@ -167,7 +167,7 @@
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Page</span>
<span class="mobile-card-value small text-muted" style="font-family:monospace">@Html.Raw(u.CurrentPath ?? "&mdash;")</span>
<span class="mobile-card-value small text-muted" style="font-family:monospace">@(u.CurrentPath ?? "&mdash;")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Last Seen</span>
@@ -205,7 +205,7 @@
<td>
<a asp-controller="Bills" asp-action="Details" asp-route-id="@bill.Id">@bill.BillNumber</a>
</td>
<td class="text-muted small">@Html.Raw(bill.DueDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-muted small">@(bill.DueDate?.ToString("MMM d, yyyy") ?? "&mdash;")</td>
<td class="text-end">@bill.BalanceDue.ToString("C")</td>
<td class="text-end" style="width:150px">
<form asp-action="Apply" method="post" class="d-inline">
@@ -106,7 +106,7 @@
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
@Html.Raw(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "&mdash;")
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "&mdash;")
</span>
</div>
@if (!string.IsNullOrWhiteSpace(vc.Memo))
@@ -70,9 +70,9 @@
<td>
<strong>@vendor.CompanyName</strong>
</td>
<td>@Html.Raw(vendor.ContactName ?? "&mdash;")</td>
<td>@Html.Raw(vendor.Phone ?? "&mdash;")</td>
<td>@Html.Raw(vendor.Email ?? "&mdash;")</td>
<td>@(vendor.ContactName ?? "&mdash;")</td>
<td>@(vendor.Phone ?? "&mdash;")</td>
<td>@(vendor.Email ?? "&mdash;")</td>
<td>
@if (vendor.InventoryItemCount > 0)
{
@@ -413,15 +413,10 @@
// ── Helpers ───────────────────────────────────────────────────────────────
function ensureAbsoluteUrl(url) {
if (!url) return url;
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
}
function syncLinkButton(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
@@ -550,15 +550,10 @@
});
}
function ensureAbsoluteUrl(url) {
if (!url) return url;
return /^https?:\/\//i.test(url) ? url : 'http://' + url;
}
function syncLink(inputId, linkId, url) {
const link = document.getElementById(linkId);
if (!link) return;
if (url) { link.href = ensureAbsoluteUrl(url); link.classList.remove('d-none'); }
if (url) { link.href = url; link.classList.remove('d-none'); }
else { link.classList.add('d-none'); }
}
@@ -150,18 +150,13 @@ public class QuoteAndReworkControllerFlowTests
DefectDescription = "Thin coverage on one edge",
DiscoveredBy = ReworkDiscoveredBy.Internal,
DiscoveredDate = new DateTime(2026, 5, 9),
EstimatedReworkCost = 65m,
CreateReworkJob = true,
ReworkJobItemIds = [10],
ReworkPricingType = ReworkPricingType.CustomerFull
EstimatedReworkCost = 65m
});
Assert.IsType<JsonResult>(result);
var reworkJob = await context.Jobs.SingleAsync(j => j.IsReworkJob);
Assert.Equal(1, reworkJob.OriginalJobId);
Assert.Equal("JOB-2605-0001-R1", reworkJob.JobNumber);
Assert.Equal(2, reworkJob.JobStatusId); // first non-Pending status
var reworkItem = await context.JobItems.SingleAsync(i => i.JobId == reworkJob.Id);
Assert.True(reworkItem.IsSalesItem);
@@ -289,9 +284,13 @@ public class QuoteAndReworkControllerFlowTests
CompanyName = "Acme Fabrication"
});
context.JobStatusLookups.AddRange(
new JobStatusLookup { Id = 1, CompanyId = 1, StatusCode = "PENDING", DisplayName = "Pending", DisplayOrder = 1 },
new JobStatusLookup { Id = 2, CompanyId = 1, StatusCode = "IN_PREPARATION", DisplayName = "In Preparation", DisplayOrder = 2 });
context.JobStatusLookups.Add(new JobStatusLookup
{
Id = 1,
CompanyId = 1,
StatusCode = "PENDING",
DisplayName = "Pending"
});
context.JobPriorityLookups.Add(new JobPriorityLookup
{