Compare commits

...

15 Commits

Author SHA1 Message Date
spouliot 7020797a25 Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController)
- Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js)
- Fix quote preview not recalculating on customer change (Create.cshtml)
- Add AddQuotePricingSnapshotFields migration (missing from prior session)
- Fix intake button rendering ✓ as literal text (Html.Raw fix)
- Clean up corrupted Unicode box-drawing chars in Job Details view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:39 -04:00
spouliot 3b5511a703 Fix corrupted Unicode characters and intake button rendering in Job Details
- Replace mojibake box-drawing chars (U+2500 encoded as Windows-1252) with
  plain ASCII dashes throughout all comments in Details.cshtml
- Fix intake button showing literal '&#10003;' text: the entity was inside a
  C# string so Razor HTML-encoded the '&'; switched to Html.Raw() so the
  checkmark renders correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:36 -04:00
spouliot 8df37ca760 Fix tax-exempt customers charged tax on all job save paths
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:15:43 -04:00
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00
spouliot 09e077897b Fix quote preview not recalculating when tax-exempt customer is selected
When a customer was changed to/from a tax-exempt customer, the hidden
TaxPercent field was correctly updated to 0 but the live pricing preview
was not re-run, so the display showed a stale total with tax applied.
Selecting a tax-exempt customer now immediately triggers a recalc so
the on-screen total matches the amount that will be saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:58:20 -04:00
spouliot 051c86810e Add missing AddQuotePricingSnapshotFields migration
Seven new decimal columns on Quotes table that were added to the entity
in the pricing audit but the migration was never created (name collision
with a prior attempt in the previous session caused the scaffold to fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:46 -04:00
spouliot 6721de91e4 Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so
  the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
  SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:03:06 -04:00
spouliot 226a6237a6 Fix corrupted Unicode characters in Jobs/Details.cshtml
All � replacement characters replaced with correct HTML entities
(&mdash;, &ndash;, &bull;, &times;, &hellip;) and restored a
corrupted class attribute with missing double quotes on the Intake button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:51:00 -04:00
spouliot cf6acc125f Complete mobile card view coverage for all remaining pages
- CSS fix: change blanket .table-responsive hide to only trigger when
  a .mobile-card-view sibling exists (.mobile-card-view ~ .table-responsive
  and :has() rule) — auto-fixes 60+ forms/reports/detail/help pages that
  were showing blank on mobile by making their tables scroll instead
- Add mobile card views to remaining list pages:
  JobsPriority (overdue jobs, main board, maintenance sections)
  NotificationLogs (email/SMS log entries)
  AiUsageReport (per-company AI usage breakdown)
  GiftCertificates/BulkResult (batch certificate list)
  Inventory/SamplePanels (Need to Order + On Wall tabs)
  BannedIps (active bans + lifted/expired bans)
  OnboardingProgress (per-company activation funnel)
  ReleaseNotes/Manage (versioned changelog entries)
  StorageMigration/Results (file migration status list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:31:38 -04:00
spouliot f467862877 Add mobile card views to 12 high-priority list pages
Pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px. Added .mobile-card-view sections to: GiftCertificates, PurchaseOrders,
CreditMemos, VendorCredits, JournalEntries, Appointments, InAppNotifications,
BankReconciliations, FixedAssets, RecurringTemplates, SmsAgreements, SmsConsentAudit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:52 -04:00
spouliot 7ad7d84016 Add mobile card views to Invoices and Intakes list pages
Both pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px but neither page had a .mobile-card-view section. Added card-per-row
mobile layout to match the Customers page pattern — tappable cards with status
badges, key fields, and action buttons sized for touch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:51:22 -04:00
spouliot 75b0a8afe2 Fix kiosk inactivity timer for remote sessions; make Intakes table mobile-responsive
Remote sessions (customer's phone) no longer get the 45-second inactivity redirect
that requires a KioskDevice cookie — would have landed them on an error page.
Intakes staff table hides non-essential columns on small screens so the primary
customer/status/actions columns are visible without horizontal scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:00:43 -04:00
spouliot 38748c2152 Add BatchId to GiftCertificate for persistent bulk batch tracking
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.

Migration: AddGiftCertificateBatchId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:32:56 -04:00
spouliot 4ec55e7290 Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:09:22 -04:00
spouliot 3eda91f170 Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (&mdash; &ndash; &times; &hellip; &lsquo; &rsquo;)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
54 changed files with 35772 additions and 242 deletions
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
}
public class GiftCertificateDto : GiftCertificateListDto
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)]
public decimal Amount { get; set; }
}
public class BulkCreateGiftCertificateDto
{
[Required]
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
[Display(Name = "Number of Certificates")]
public int Quantity { get; set; } = 25;
[Required]
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
[Display(Name = "Face Value (each)")]
public decimal Amount { get; set; }
[Required]
[Display(Name = "Issued Reason")]
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
[Display(Name = "Expiry Date (optional)")]
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)]
[Display(Name = "Event / Notes (applied to all certificates)")]
public string? Notes { get; set; }
}
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
public decimal SubtotalBeforeDiscount { get; set; }
public decimal PricingTierDiscountAmount { get; set; }
public decimal PricingTierDiscountPercent { get; set; }
public decimal QuoteDiscountAmount { get; set; }
public decimal QuoteDiscountPercent { get; set; }
public decimal DiscountAmount { get; set; }
public decimal DiscountPercent { get; set; }
@@ -51,4 +51,10 @@ public interface IPdfService
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
}
@@ -27,7 +27,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m,
LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
@@ -113,7 +113,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m,
LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
});
}
/// <summary>
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
/// <summary>
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
@@ -35,6 +35,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
@@ -42,8 +44,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; }
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
public Guid? BatchId { get; set; }
// Navigation
public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; }
+4
View File
@@ -66,6 +66,10 @@ public class Job : BaseEntity
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
// the breakdown that was actually calculated, not a re-run against current operating costs.
public string? PricingBreakdownJson { get; set; }
// Rework tracking
public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
+14 -7
View File
@@ -45,21 +45,28 @@ public class Quote : BaseEntity
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount
public decimal OverheadPercent { get; set; } // Overhead percentage used
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
public decimal ProfitPercent { get; set; } // Profit margin percentage used
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
public decimal OverheadPercent { get; set; } // Legacy overhead percent
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
// Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None;
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGiftCertificateBatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BatchId",
table: "GiftCertificates",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchId",
table: "GiftCertificates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobPricingSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PricingBreakdownJson",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PricingBreakdownJson",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
}
}
}
@@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddQuotePricingSnapshotFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadCost",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadRatePerHour",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "SubtotalAfterDiscount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FacilityOverheadCost",
table: "Quotes");
migrationBuilder.DropColumn(
name: "FacilityOverheadRatePerHour",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "SubtotalAfterDiscount",
table: "Quotes");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
}
}
@@ -3290,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -4214,6 +4217,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("OvenCycleMinutes")
.HasColumnType("int");
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId")
.HasColumnType("int");
@@ -6708,7 +6714,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6719,7 +6725,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6730,7 +6736,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482),
CreatedAt = new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -6977,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime2");
b.Property<decimal>("FacilityOverheadCost")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("FacilityOverheadRatePerHour")
.HasColumnType("decimal(18,2)");
b.Property<bool>("HideDiscountFromCustomer")
.HasColumnType("bit");
@@ -7022,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<decimal>("PricingTierDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("PricingTierDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ProfitMargin")
.HasColumnType("decimal(18,2)");
@@ -7061,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("QuoteDate")
.HasColumnType("datetime2");
b.Property<decimal>("QuoteDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("QuoteDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("QuoteNumber")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -7086,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("SubTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("SubtotalAfterDiscount")
.HasColumnType("decimal(18,2)");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
IssuedReason = gc.IssuedReason,
Status = gc.Status,
IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate
ExpiryDate = gc.ExpiryDate,
BatchId = gc.BatchId
})
.ToList();
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
return acct?.Id;
}
/// <summary>
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
/// since the primary use case is car shows and events where a batch of same-value certificates
/// is distributed to attendees.
/// </summary>
public IActionResult BulkCreate()
{
return View(new BulkCreateGiftCertificateDto());
}
/// <summary>
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
/// Sales Discounts (4950) and credit GC Liability (2500).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
int? checkingAcctId = null;
int? discountAcctId = null;
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id;
}
else
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id;
}
var batchId = Guid.NewGuid();
var now = DateTime.UtcNow;
for (int i = 0; i < dto.Quantity; i++)
{
var code = await GenerateCertificateCodeAsync(companyId);
var cert = new GiftCertificate
{
CertificateCode = code,
OriginalAmount = dto.Amount,
RedeemedAmount = 0,
IssuedReason = dto.IssuedReason,
Status = GiftCertificateStatus.Active,
IssueDate = now,
ExpiryDate = dto.ExpiryDate,
Notes = dto.Notes,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = now,
CreatedBy = currentUser?.Email,
BatchId = batchId
};
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
else
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
}
return RedirectToAction(nameof(BulkResult), new { batchId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bulk gift certificates");
this.ToastError("An error occurred creating the certificates.");
return View(dto);
}
}
/// <summary>
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
/// </summary>
public async Task<IActionResult> BulkResult(Guid batchId)
{
if (batchId == Guid.Empty)
return RedirectToAction(nameof(Index));
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false);
if (!certs.Any())
return RedirectToAction(nameof(Index));
return View(certs.OrderBy(c => c.CertificateCode).ToList());
}
/// <summary>
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
/// user can bookmark or re-open it at any time after the batch was originally created.
/// </summary>
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
{
if (batchId == Guid.Empty)
return BadRequest();
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false,
gc => gc.RecipientCustomer);
if (!certs.Any())
return NotFound();
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes
}).ToList();
try
{
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
var first = dtos.First().CertificateCode;
var last = dtos.Last().CertificateCode;
var fileName = dtos.Count == 1
? $"GiftCertificate-{first}.pdf"
: $"GiftCertificates-{first}-to-{last}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(BulkResult), new { batchId });
}
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -1,9 +1,11 @@
using System.Text.Json;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
SourceJobItemId = item.Id,
CatalogItemId = item.CatalogItemId,
Description = item.Description ?? "Powder Coating",
Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice,
ColorName = item.ColorName,
Notes = item.Notes,
DisplayOrder = order++,
RevenueAccountId = revenueAccountId
});
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
if (sourceQuote != null)
{
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee;
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
}
else if (hadJobItems)
{
// Direct job — no source quote. Use the stored job-level fees rather than
// recalculating, so the invoice always matches the total shown on the job page.
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
// OvenCostId) when job items are created or updated.
// Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = $"Oven Processing Fee",
Description = "Oven Processing Fee",
Quantity = 1,
UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = Math.Round(job.OvenBatchCost, 2),
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
});
}
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
if (facilityOverhead > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Facility Overhead",
Quantity = 1,
UnitPrice = Math.Round(facilityOverhead, 2),
TotalPrice = Math.Round(facilityOverhead, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
if (job.ShopSuppliesAmount > 0.01m)
{
var suppliesDesc = job.ShopSuppliesPercent > 0
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
Quantity = 1,
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
var rushFee = jobBreakdown?.RushFee ?? 0m;
if (rushFee > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Rush Fee",
Quantity = 1,
UnitPrice = Math.Round(rushFee, 2),
TotalPrice = Math.Round(rushFee, 2),
DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id
});
@@ -422,72 +422,24 @@ public class JobsController : Controller
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
var breakdownItems = job.JobItems
.Where(ji => !ji.IsDeleted)
.Select(ji => new CreateQuoteItemDto
// Display the pricing snapshot stored when items were last saved.
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
{
Description = ji.Description,
Quantity = ji.Quantity,
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
PowderCostOverride = ji.PowderCostOverride,
IncludePrepCost = ji.IncludePrepCost,
Complexity = ji.Complexity,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
}
else if (job.FinalPrice > 0)
{
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes
}).ToList()
}).ToList();
if (breakdownItems.Any())
{
var pr = await _pricingService.CalculateQuoteTotalsAsync(
breakdownItems, job.CompanyId, job.CustomerId,
wizardCosts?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
// Legacy job created before snapshot was introduced — show what we have stored
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
OvenBatchCost = job.OvenBatchCost,
OvenBatches = job.OvenBatches,
ShopSuppliesAmount = job.ShopSuppliesAmount,
ShopSuppliesPercent = job.ShopSuppliesPercent,
Total = job.FinalPrice
};
}
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
@@ -1169,15 +1121,23 @@ public class JobsController : Controller
// Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
if (dto.OvenCostId.HasValue)
{
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
if (createOven != null && createOven.CompanyId == companyId)
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync();
@@ -1629,14 +1589,22 @@ public class JobsController : Controller
if (dto.JobItems.Any())
{
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
if (job.OvenCostId.HasValue)
{
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (editOven != null && editOven.CompanyId == companyId)
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
}
// Save change history records
@@ -2962,7 +2930,7 @@ public class JobsController : Controller
JobId = job.Id,
JobNumber = job.JobNumber,
CustomerId = job.CustomerId,
TaxPercent = costs?.TaxPercent ?? 0m,
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
@@ -2999,7 +2967,7 @@ public class JobsController : Controller
{
ModelState.AddModelError("", "Please add at least one job item.");
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m;
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
@@ -3044,15 +3012,26 @@ public class JobsController : Controller
}
}
// Calculate full total (overhead, margins, tax) to match what the wizard displays
// Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
{
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (oven != null && oven.CompanyId == currentUser.CompanyId)
ovenRateOverride = oven.CostPerHour;
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, job.OvenCostId, job.OvenBatches, job.OvenCycleMinutes);
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3066,7 +3045,7 @@ public class JobsController : Controller
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
TempData["Error"] = "An error occurred while saving job items.";
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m;
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
return View("EditItems", model);
}
@@ -3108,31 +3087,47 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem,
ManualUnitPrice = ji.ManualUnitPrice,
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb
}).ToList()
}).ToList();
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
if (remainingDtos.Any())
{
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
decimal? deleteOvenRate = null;
if (job.OvenCostId.HasValue)
{
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
deleteOvenRate = deleteOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
}
else
{
job.FinalPrice = 0;
job.OvenBatchCost = 0;
job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0;
job.PricingBreakdownJson = null;
}
job.UpdatedAt = DateTime.UtcNow;
@@ -3242,6 +3237,57 @@ public class JobsController : Controller
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
}
/// <summary>
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
/// </summary>
/// <summary>
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
/// are never charged tax when a job is saved or recalculated.
/// </summary>
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
{
if (customerId is > 0)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
if (customer?.IsTaxExempt == true) return 0m;
}
return companyDefaultRate;
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes,
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
QuoteDiscountAmount = pr.QuoteDiscountAmount,
QuoteDiscountPercent = pr.QuoteDiscountPercent,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
};
#endregion
#region Item Pricing (AJAX)
@@ -916,8 +916,13 @@ public class KioskController : Controller
ViewBag.SessionToken = session.SessionToken;
ViewBag.SessionType = session.SessionType;
// Reset to Welcome screen after 45 s of inactivity on any intake step.
// The Welcome screen itself stays on indefinitely (no timeout override there).
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
// Remote sessions: customer is on their own phone — never redirect; they may
// take several minutes between steps and have no KioskDevice cookie anyway.
if (session.SessionType == KioskSessionType.InPerson)
ViewBag.InactivityTimeoutMs = 45_000;
else
ViewBag.ShowInactivityTimer = false;
}
}
@@ -1,4 +1,5 @@
using AutoMapper;
using System.Text.Json;
using AutoMapper;
using Microsoft.Extensions.Configuration;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
@@ -2847,8 +2848,39 @@ public class QuotesController : Controller
JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
QuoteDiscountAmount = quote.QuoteDiscountAmount,
QuoteDiscountPercent = quote.QuoteDiscountPercent,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
RushFee = quote.RushFee,
TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent,
Total = quote.Total
}),
CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
@@ -232,4 +232,3 @@
});
</script>
}
@@ -109,6 +109,69 @@
<span class="fw-semibold">Per-Company Breakdown</span>
<span class="text-muted small">@Model.Rows.Count companies total</span>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
<i class="bi bi-robot"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Today</span>
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">30 Days</span>
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">All Time</span>
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
@if (row.TopFeature != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Top Feature</span>
<span class="mobile-card-value">
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Tier</span>
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-building me-1"></i>Company
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
No AI usage logged yet.
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
<thead class="table-light">
@@ -176,6 +176,60 @@
<div class="card-body">
@if (Model.Items.Any())
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var appointment in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-calendar-event"></i>
</div>
<div class="mobile-card-title">
<h6>@appointment.Title</h6>
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} &ndash; {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
</span>
</div>
@if (!string.IsNullOrEmpty(appointment.CustomerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Customer</span>
<span class="mobile-card-value">@appointment.CustomerName</span>
</div>
}
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
@@ -21,6 +21,64 @@
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var br in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
<i class="bi bi-bank"></i>
</div>
<div class="mobile-card-title">
<h6>@br.Account?.Name</h6>
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (br.Status == BankReconciliationStatus.Completed)
{
<span class="badge bg-success">Completed</span>
}
else
{
<span class="badge bg-warning text-dark">In Progress</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Ending Balance</span>
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
</div>
@if (br.CompletedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Completed By</span>
<span class="mobile-card-value">@br.CompletedBy</span>
</div>
}
</div>
<div class="mobile-card-footer">
@if (br.Status == BankReconciliationStatus.Completed)
{
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Report
</a>
}
else
{
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-check2-square me-1"></i>Continue
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -60,6 +60,59 @@
<div class="card-body p-0">
@if (active.Any())
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in active)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
<i class="bi bi-slash-circle"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value">
@if (ban.ExpiresAt.HasValue)
{
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
}
else
{
<span class="badge bg-secondary">Permanent</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-success">
<i class="bi bi-check-circle me-1"></i>Lift
</button>
</form>
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -130,6 +183,55 @@
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
</div>
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in inactive)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
<i class="bi bi-clock-history"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small>
@if (!ban.IsActive)
{
<span class="badge bg-success">Lifted</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(ban.Reason))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Reason</span>
<span class="mobile-card-value text-muted">@ban.Reason</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
@@ -101,6 +101,73 @@
else
{
<div class="card">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var m in Model)
{
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
&& m.Status != CreditMemoStatus.FullyApplied
&& m.Status != CreditMemoStatus.Voided;
var (cmBadge, cmLabel) = m.Status switch
{
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
};
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
: m.Customer!.CompanyName;
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
<i class="bi bi-journal-minus"></i>
</div>
<div class="mobile-card-title">
<h6>@m.MemoNumber</h6>
<small>@cmCustomer</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @cmBadge">@cmLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Amount</span>
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
@m.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
</div>
@if (m.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
@if (expired2) { <small>(Expired)</small> }
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
Details
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -118,6 +118,63 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var a in Model)
{
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
<i class="bi bi-building-gear"></i>
</div>
<div class="mobile-card-title">
<h6>@a.Name</h6>
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (a.IsDisposed)
{
<span class="badge bg-secondary">Disposed</span>
}
else if (fd)
{
<span class="badge bg-light text-dark border">Fully Depreciated</span>
}
else
{
<span class="badge bg-success">Active</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Cost</span>
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Book Value</span>
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
@a.BookValue.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Monthly Depr.</span>
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -0,0 +1,107 @@
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bulk Create Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
</h5>
<p class="text-muted small mb-0 mt-1">
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
face value and be generated with sequential codes ready to print.
</p>
</div>
<div class="card-body p-4">
<form asp-action="BulkCreate" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="row g-3">
<div class="col-md-5">
<label asp-for="Quantity" class="form-label fw-semibold">
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
</label>
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
min="1" max="500" placeholder="25" />
<span asp-validation-for="Quantity" class="text-danger small"></span>
<div class="form-text">Max 500 per batch.</div>
</div>
<div class="col-md-7">
<label asp-for="Amount" class="form-label fw-semibold">
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
</label>
<div class="input-group input-group-lg">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" class="form-control"
min="1" max="9999.99" step="0.01" placeholder="50.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="IssuedReason" class="form-label fw-semibold">
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
</label>
<select asp-for="IssuedReason" class="form-select">
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
{
<option value="@reason">@reason</option>
}
</select>
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="ExpiryDate" class="form-label fw-semibold">
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
</label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
<div class="form-text">Leave blank for no expiration.</div>
</div>
<div class="col-12">
<label asp-for="Notes" class="form-label fw-semibold">
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
</label>
<textarea asp-for="Notes" class="form-control" rows="2"
placeholder="e.g. Awarded at the 2026 Summer Car Show &mdash; thanks for attending!"></textarea>
<span asp-validation-for="Notes" class="text-danger small"></span>
<div class="form-text">Printed on every certificate in the batch.</div>
</div>
</div>
<!-- Preview summary -->
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
<i class="bi bi-info-circle me-2"></i>
You are about to create <strong id="prevQty"></strong> certificates worth
<strong id="prevAmt"></strong> each &mdash; total face value
<strong id="prevTotal"></strong>.
</div>
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-plus-circle me-2"></i>Create Certificates
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,118 @@
@model List<PowderCoating.Core.Entities.GiftCertificate>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Batch Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
var count = Model.Count;
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
}
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>@count gift certificates created</strong> &mdash; each worth @amount.ToString("C").
Download the PDF below to print the full batch. This page is bookmarkable &mdash; you can return here any time to re-download.
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]&hellip;</span>
</h5>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
</a>
</div>
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@cert.OriginalAmount.ToString("C")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expiry</span>
<span class="mobile-card-value">
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-pdf me-1"></i>PDF
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Certificate Code</th>
<th>Face Value</th>
<th>Issued</th>
<th>Expiry</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in Model)
{
<tr>
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
<td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td>
@(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;")
</td>
<td><span class="badge bg-success">Active</span></td>
<td class="text-end">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
<i class="bi bi-file-pdf"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
</a>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
</a>
</div>
</div>
@@ -28,7 +28,7 @@
</div>
</div>
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
<div>
<strong>@statusLabel</strong>
@@ -38,7 +38,7 @@
}
@if (Model.ExpiryDate.HasValue)
{
<span class="ms-2 small">&middot; Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
}
</div>
</div>
@@ -7,11 +7,16 @@
}
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates &mdash; @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<div class="d-flex gap-2">
<a asp-action="BulkCreate" class="btn btn-outline-primary">
<i class="bi bi-collection me-2"></i>Bulk Create
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
</div>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
@@ -52,6 +57,73 @@ else
{
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
var (gcBadge, gcLabel) = cert.Status switch
{
GiftCertificateStatus.Active => ("bg-success", "Active"),
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", cert.Status.ToString())
};
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @gcBadge">@gcLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Face Value</span>
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
@cert.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
</div>
@if (cert.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -75,6 +147,14 @@ else
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
@cert.CertificateCode
</a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
title="View &amp; download batch">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</td>
<td>
@if (!string.IsNullOrEmpty(cert.RecipientName))
@@ -83,7 +163,7 @@ else
}
else
{
<span class="text-muted"></span>
<span class="text-muted">&mdash;</span>
}
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
{
@@ -29,6 +29,61 @@
else
{
<div class="card border-0 shadow-sm">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var n in items)
{
bool mIsRead = (bool)n.IsRead;
string mTitle = (string)n.Title;
string mMessage = (string)n.Message;
string? mLink = (string?)n.Link;
string mType = (string)n.NotificationType;
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
data-id="@n.Id"
data-title="@mTitle"
data-message="@mMessage"
data-link="@(mLink ?? "")"
data-type="@mType"
data-is-read="@(mIsRead ? "1" : "0")"
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-bell"></i>
</div>
<div class="mobile-card-title">
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
@if (!mIsRead)
{
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
}
@mTitle
</h6>
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
</div>
<div class="mobile-card-row" style="align-items:flex-start;">
<span class="mobile-card-label">Message</span>
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
</div>
</div>
@if (!string.IsNullOrEmpty(mLink))
{
<div class="mobile-card-footer">
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-arrow-right me-1"></i>Open
</a>
</div>
}
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -126,6 +126,77 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@{ lastMfr = null; }
@foreach (var item in needOrder)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</div>
}
<div class="mobile-data-card">
<div class="mobile-card-header">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
}
else
{
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #64748b 0%, #475569 100%);">
<i class="bi bi-palette"></i>
</div>
}
<div class="mobile-card-title">
<h6>@(item.ColorName ?? item.Name)</h6>
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Part #</span>
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(item.Finish))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Finish</span>
<span class="mobile-card-value">@item.Finish</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">In Stock</span>
<span class="mobile-card-value">
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
}
else
{
<span class="text-muted">None</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-success btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="true">
<i class="bi bi-check-lg me-1"></i>Got It
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" id="needTable">
<thead class="table-group-divider">
@@ -220,6 +291,68 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@{ lastMfr = null; }
@foreach (var item in onHand)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</div>
}
<div class="mobile-data-card">
<div class="mobile-card-header">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
}
else
{
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #059669 0%, #047857 100%);">
<i class="bi bi-palette"></i>
</div>
}
<div class="mobile-card-title">
<h6>@(item.ColorName ?? item.Name)</h6>
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Part #</span>
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(item.Finish))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Finish</span>
<span class="mobile-card-value">@item.Finish</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>On Wall</span></span>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-danger btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="false">
<i class="bi bi-x-lg me-1"></i>Remove
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-group-divider">
@@ -144,7 +144,7 @@
</td>
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "")
@(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")">
@@ -167,6 +167,77 @@
</tbody>
</table>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var inv in Model.Items)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
<div class="mobile-card-header" style="@(inv.IsOverdue ? "background:#fee2e2;" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-receipt"></i>
</div>
<div class="mobile-card-title">
<h6>@inv.InvoiceNumber</h6>
<small>@inv.CustomerName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
</span>
</div>
@if (inv.JobId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Job</span>
<span class="mobile-card-value">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
class="text-decoration-none" onclick="event.stopPropagation()">
@inv.JobNumber
</a>
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Date</span>
<span class="mobile-card-value">@inv.InvoiceDate.ToString("MM/dd/yy")</span>
</div>
@if (inv.DueDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value @(inv.IsOverdue ? "fw-bold text-danger" : "")">
@inv.DueDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value">@inv.Total.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Balance Due</span>
<span class="mobile-card-value @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@inv.BalanceDue.ToString("C")
</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-file-pdf me-1"></i>PDF
</a>
</div>
</div>
}
</div>
</div>
<div class="px-3">
@await Html.PartialAsync("_Pagination", Model)
</div>
+78 -78
View File
@@ -57,7 +57,7 @@
}
else
{
<span>Shop work has started � review the quote and apply any changes manually.</span>
<span>Shop work has started &mdash; review the quote and apply any changes manually.</span>
}
</div>
<div class="d-flex gap-2 flex-wrap">
@@ -217,7 +217,7 @@
</button>
</div>
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</div>
</div>
</div>
@@ -263,7 +263,7 @@
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
</button>
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</div>
</div>
</div>
@@ -273,7 +273,7 @@
<div class="d-flex align-items-center gap-2">
<select id="workerAssignmentSelect" class="form-select form-select-sm"
onchange="updateWorkerAssignment(this)">
<option value="">� Unassigned �</option>
<option value="">&ndash; Unassigned &ndash;</option>
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
{
if (w.Value == Model.AssignedUserId)
@@ -287,7 +287,7 @@
}
</select>
<span id="workerSaveIndicator" class="text-muted small d-none">
<span class="spinner-border spinner-border-sm me-1"></span>Saving�
<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;
</span>
<span id="workerSavedTick" class="text-success small d-none">
<i class="bi bi-check-circle-fill"></i>
@@ -321,7 +321,7 @@
<div class="card-body">
@* ── Catalog Products ── *@
@* -- Catalog Products -- *@
@if (catalogItems.Any())
{
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
@@ -351,10 +351,10 @@
{
<br />
<small class="ms-3">
� <strong>@coat.CoatName</strong>
&bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> � @coat.ColorName</text>
<text> &ndash; @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
@@ -373,7 +373,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder &mdash; must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
@@ -390,7 +390,7 @@
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
<small class="ms-3">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
@@ -414,7 +414,7 @@
</div>
}
@* ── Custom Work ── *@
@* -- Custom Work -- *@
@if (customItems.Any())
{
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
@@ -478,10 +478,10 @@
{
<br />
<small class="ms-3">
� <strong>@coat.CoatName</strong>
&bull; <strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> � @coat.ColorName</text>
<text> &ndash; @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
@@ -500,7 +500,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder � must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder &mdash; must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
@@ -517,7 +517,7 @@
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3">� <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">� @ps.EstimatedMinutes min</span></small>
<small class="ms-3">&bull; <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted">&ndash; @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
@@ -532,7 +532,7 @@
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted">�</span> }
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
@@ -540,7 +540,7 @@
<text>@item.EstimatedMinutes min</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted">�</span> }
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-center">
@if (totalPowderNeeded > 0)
@@ -548,7 +548,7 @@
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
<br /><small class="text-muted">total batch</small>
}
else { <span class="text-muted">�</span> }
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -565,7 +565,7 @@
</div>
}
@* ── Labor ── *@
@* -- Labor -- *@
@if (laborItems.Any())
{
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
@@ -599,7 +599,7 @@
{
<text>@item.EstimatedMinutes min</text>
}
else { <span class="text-muted">�</span> }
else { <span class="text-muted">&mdash;</span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -616,7 +616,7 @@
</div>
}
@* ── Mobile cards ── *@
@* -- Mobile cards -- *@
<div class="d-lg-none mt-2">
@foreach (var item in Model.Items)
{
@@ -653,7 +653,7 @@
<span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" � {coat.ColorName}" : "")</small>
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> &ndash; @coat.ColorName</text> }</small>
}
</span>
</div>
@@ -704,7 +704,7 @@
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small">Total: <strong id="totalHoursDisplay">�</strong></span>
<span class="text-muted small">Total: <strong id="totalHoursDisplay">&mdash;</strong></span>
@{
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
var estimatedHrs = estimatedMins / 60m;
@@ -741,7 +741,7 @@
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="3">Total</td>
<td class="text-end" id="timeEntriesTotalHours">�</td>
<td class="text-end" id="timeEntriesTotalHours">&mdash;</td>
<td colspan="3"></td>
</tr>
</tfoot>
@@ -1099,7 +1099,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="intakeModalLabel">
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake � Check In
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake &ndash; Check In
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
@@ -1117,7 +1117,7 @@
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
placeholder="@intakeExpectedCount" />
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected � note the discrepancy below.
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected &mdash; note the discrepancy below.
</div>
</div>
<div class="mb-3">
@@ -1310,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class=�bi bi-box-seam me-2�></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
<i class="bi bi-box-seam me-2"></i>@Html.Raw(Model.IntakeDate.HasValue ? "Intake &#10003;" : "Intake")
</a>
}
@{
@@ -1368,7 +1368,7 @@
</div>
</div>
<!-- Pricing Summary (internal � d-print-none) -->
<!-- Pricing Summary (internal - d-print-none) -->
@{
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
}
@@ -1400,7 +1400,7 @@
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" � {jobPb.OvenCycleMinutes} min" : "")):</span>
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" &times; {jobPb.OvenCycleMinutes} min" : "")):</span>
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
</div>
}
@@ -1518,7 +1518,7 @@
}
else if (allCatalog)
{
<div class="text-muted small fst-italic">All items use fixed catalog pricing � no per-category cost split available.</div>
<div class="text-muted small fst-italic">All items use fixed catalog pricing &mdash; no per-category cost split available.</div>
}
else
{
@@ -1547,7 +1547,7 @@
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr � estimated hours)</span>
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr &times; estimated hours)</span>
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
</div>
}
@@ -1712,11 +1712,11 @@
<div class="px-3 pt-3 pb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
<span class="fw-semibold" id="costingRevenue">�</span>
<span class="fw-semibold" id="costingRevenue">&mdash;</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
<span id="costingPowder">�</span>
<span id="costingPowder">&mdash;</span>
</div>
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1725,7 +1725,7 @@
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
<span id="costingLabor">�</span>
<span id="costingLabor">&mdash;</span>
</div>
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1734,12 +1734,12 @@
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
<span id="costingOven">�</span>
<span id="costingOven">&mdash;</span>
</div>
<div id="costingReworkSection" style="display:none;">
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
<span id="costingRework">�</span>
<span id="costingRework">&mdash;</span>
</div>
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1748,25 +1748,25 @@
</div>
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
<span>Billed to Customer</span>
<span id="costingReworkBilled">�</span>
<span id="costingReworkBilled">&mdash;</span>
</div>
</div>
<hr class="my-2" />
<div class="d-flex justify-content-between small mb-1 ps-2">
<span class="text-muted">Total Costs</span>
<span id="costingTotal" class="text-danger">�</span>
<span id="costingTotal" class="text-danger">&mdash;</span>
</div>
<div class="d-flex justify-content-between fw-bold mb-1">
<span>Gross Profit</span>
<span id="costingProfit">�</span>
<span id="costingProfit">&mdash;</span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Gross Margin</span>
<span id="costingMargin">�</span>
<span id="costingMargin">&mdash;</span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>Margin vs Quote</span>
<span id="costingQuotedMargin">�</span>
<span id="costingQuotedMargin">&mdash;</span>
</div>
</div>
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
@@ -1869,7 +1869,7 @@
</div>
<div class="mb-3">
<label class="form-label">Tags
<small class="text-muted fw-normal ms-1">� colors, finish, or other keywords</small>
<small class="text-muted fw-normal ms-1">&ndash; colors, finish, or other keywords</small>
</label>
<input type="hidden" id="photoTagsHidden" name="tags" />
<div id="photoTagsContainer"></div>
@@ -1948,7 +1948,7 @@
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">� colors, finish, keywords</small></label>
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1">&ndash; colors, finish, keywords</small></label>
<input type="hidden" id="editPhotoTagsHidden" />
<div id="editPhotoTagsContainer"></div>
</div>
@@ -2000,7 +2000,7 @@
<div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message�" maxlength="160"></textarea>
placeholder="Type your message&hellip;" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
@@ -2012,7 +2012,7 @@
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip � don't send
Skip &mdash; don't send
</button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS
@@ -2133,7 +2133,7 @@
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value="">� Whole Job �</option>
<option value="">&ndash; Whole Job &ndash;</option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
@@ -2195,9 +2195,9 @@
<div class="col-md-6">
<label class="form-label">Resolution</label>
<select class="form-select" id="rwResolution">
<option value="">� Pending �</option>
<option value="0">Recoated � No Charge</option>
<option value="1">Recoated � Billed to Customer</option>
<option value="">&ndash; Pending &ndash;</option>
<option value="0">Recoated &mdash; No Charge</option>
<option value="1">Recoated &mdash; Billed to Customer</option>
<option value="2">Customer Credited</option>
<option value="3">Written Off</option>
<option value="4">No Action Required</option>
@@ -2256,7 +2256,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
<select class="form-select" id="teWorkerId">
<option value="">� Select worker �</option>
<option value="">&ndash; Select worker &ndash;</option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{
<option value="@w.Id">@w.Name</option>
@@ -2275,7 +2275,7 @@
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Stage / Task</label>
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking�" list="stageOptions" />
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking&hellip;" list="stageOptions" />
<datalist id="stageOptions">
<option value="Sandblasting"></option>
<option value="Masking & Taping"></option>
@@ -2290,7 +2290,7 @@
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes�"></textarea>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes&hellip;"></textarea>
</div>
<div class="text-danger small d-none" id="teError"></div>
</div>
@@ -2332,7 +2332,7 @@
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
// ── Inline date editing ──────────────────────────────────────────────
// -- Inline date editing ----------------------------------------------
const jobId = @Model.Id;
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
@@ -2433,7 +2433,7 @@
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
// ── Auto-submit after wizard saves an item ────────────────────────
// -- Auto-submit after wizard saves an item ------------------------
let itemsModified = false;
// Wrap wizardSave to set a flag before the modal hides
@@ -2451,12 +2451,12 @@
}
});
// ── Delete confirmation modal ─────────────────────────────────────
// -- Delete confirmation modal -------------------------------------
let pendingDeleteItemId = -1;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Delegated listener � handles all delete buttons via data attributes
// Delegated listener -- handles all delete buttons via data attributes
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-delete-id]');
if (!btn) return;
@@ -2489,7 +2489,7 @@
});
</script>
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
<!-- -- Rework / Warranty ---------------------------------------------- -->
<script>
const rework = (() => {
const jid = @Model.Id;
@@ -2534,12 +2534,12 @@
</div>
<div class="small mt-1 text-muted">${r.defectDescription}</div>
<div class="small text-muted mt-1">
Found: ${r.discoveredByDisplay} � ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? '� ' + r.reportedByName : ''}
Found: ${r.discoveredByDisplay} &mdash; ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? '&ndash; ' + r.reportedByName : ''}
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
</div>
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' � $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' &mdash; $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
</div>`).join('');
}
@@ -2645,7 +2645,7 @@
})();
</script>
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
<!-- -- Job Costing ---------------------------------------------------- -->
<script>
const costing = (() => {
const jid = @Model.Id;
@@ -2685,7 +2685,7 @@
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
const rBody = document.getElementById('reworkCostLines');
rBody.innerHTML = d.reworkLines.map(l => `<tr>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} � ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} &ndash; ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
} else {
@@ -2701,14 +2701,14 @@
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
document.getElementById('costingQuotedMargin').textContent =
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '�';
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '';
// Powder detail lines
const pBody = document.getElementById('powderLines');
pBody.innerHTML = d.hasPowderData
? d.powderLines.map(l => `<tr>
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
<td class="text-end text-nowrap">${l.lbs} lbs � ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap">${l.lbs} lbs &times; ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
@@ -2716,14 +2716,14 @@
const lBody = document.getElementById('laborLines');
lBody.innerHTML = d.hasLaborData
? d.laborLines.map(l => `<tr>
<td class="text-muted">${l.worker}${l.stage ? ' � ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h � ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-muted">${l.worker}${l.stage ? ' &ndash; ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h &times; ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
// Notes
const notes = [];
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items � edit the item and enter a surface area to calculate powder cost.');
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items &mdash; edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
@@ -2754,7 +2754,7 @@
})();
</script>
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
<!-- -- Time Tracking --------------------------------------------------- -->
<script>
const timeTracking = (() => {
const jid = @Model.Id;
@@ -2762,7 +2762,7 @@
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = [];
// ── Load ──────────────────────────────────────────────────────────
// -- Load ----------------------------------------------------------
async function load() {
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
entries = await r.json();
@@ -2793,7 +2793,7 @@
<td class="fw-semibold">${esc(e.workerName)}</td>
<td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">�</span>'}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">&mdash;</span>'}</td>
<td class="small text-muted">${esc(e.notes ?? '')}</td>
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
@@ -2805,12 +2805,12 @@
}
function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '�';
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '';
document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '�';
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '';
}
// ── Modal helpers ─────────────────────────────────────────────────
// -- Modal helpers -------------------------------------------------
function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0';
@@ -2917,7 +2917,7 @@
}
});
// ── Deposits ─────────────────────────────────────────────────────────────
// -- Deposits -------------------------------------------------------------
// Note: antiForgeryToken() is already defined above in this script block
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
@@ -2931,7 +2931,7 @@
}
if (errEl) errEl.classList.add('d-none');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving�'; }
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving&hellip;'; }
const params = new URLSearchParams(new FormData(form));
@@ -2973,7 +2973,7 @@
}
}
// ── Collapsible sections ──────────────────────────────────────────────────
// -- Collapsible sections --------------------------------------------------
(function () {
const storageKey = 'jobDetailCollapse_@Model.Id';
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
@@ -3012,7 +3012,7 @@
});
})();
// ── Part Intake Modal ─────────────────────────────────────────────────────
// -- Part Intake Modal --------------------------------------------------
(function () {
const expectedCount = @intakeExpectedCount;
const partCountInput = document.getElementById('intakePartCount');
@@ -3105,7 +3105,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input type="text" name="templateName" class="form-control" required maxlength="100"
placeholder="e.g. Wheel Refinish � Standard 4pc">
placeholder="e.g. Wheel Refinish &mdash; Standard 4pc">
</div>
<div class="mb-3">
@@ -71,6 +71,59 @@
</div>
</div>
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var job in overdueJobs)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
<i class="bi bi-exclamation-triangle"></i>
</div>
<div class="mobile-card-title">
<h6>@job.JobNumber</h6>
<small>@job.CustomerName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass">@job.StatusDisplayName</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span></span>
</div>
@if (job.ScheduledDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Scheduled</span>
<span class="mobile-card-value text-danger fw-bold">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (job.DueDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value text-danger">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')">
<i class="bi bi-flag"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
@@ -191,6 +244,74 @@
</div>
</div>
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var job in Model)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);">
<i class="bi bi-kanban"></i>
</div>
<div class="mobile-card-title">
<h6>@job.JobNumber</h6>
<small>@job.CustomerName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-@job.StatusColorClass" id="status-badge-@job.JobId">@job.StatusDisplayName</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass priority-badge-@job.JobId">@job.PriorityDisplayName</span></span>
</div>
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value"><span class="badge bg-info"><i class="bi bi-person me-1"></i>@job.AssignedWorkerName</span></span>
</div>
}
@if (job.ScheduledDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Scheduled</span>
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (job.DueDate.HasValue)
{
var mJobOverdue = job.DueDate.Value.Date < DateTime.Today;
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value @(mJobOverdue ? "text-danger fw-bold" : "")">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')" title="Change Priority">
<i class="bi bi-flag"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')" title="Assign Worker">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-check fs-1 d-block mb-2 opacity-25"></i>
No jobs scheduled for @scheduledDate.ToString("MMMM dd, yyyy").
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" id="jobsTable">
<thead>
@@ -352,6 +473,65 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in maintenanceItems)
{
var mPriorityBg = item.Priority switch
{
MaintenancePriority.Critical => "danger",
MaintenancePriority.High => "warning",
MaintenancePriority.Normal => "info",
_ => "secondary"
};
var mStatusBgM = item.Status == MaintenanceStatus.InProgress ? "success" : "primary";
var mStatusLbl = item.Status == MaintenanceStatus.InProgress ? "In Progress" : "Scheduled";
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);">
<i class="bi bi-tools"></i>
</div>
<div class="mobile-card-title">
<h6>@(item.Equipment?.EquipmentName ?? "Maintenance")</h6>
<small>@item.MaintenanceType</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@mPriorityBg">@item.Priority</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-@mStatusBgM">@mStatusLbl</span></span>
</div>
@if (item.AssignedUser != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value"><span class="badge bg-info text-dark"><i class="bi bi-person me-1"></i>@item.AssignedUser.FullName</span></span>
</div>
}
@if (!string.IsNullOrEmpty(item.Description))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Desc.</span>
<span class="mobile-card-value text-muted">@item.Description</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openMaintenanceWorkerModal(@item.Id, '@(item.AssignedUserId ?? "")', '@(item.Equipment?.EquipmentName ?? "Maintenance")')" title="Assign Worker">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
@@ -47,6 +47,68 @@
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var je in Model)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = je.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-journal-text"></i>
</div>
<div class="mobile-card-title">
<h6>
@je.EntryNumber
@if (je.IsReversal)
{
<span class="badge bg-secondary ms-1">REV</span>
}
</h6>
<small>@je.EntryDate.ToString("MMM d, yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (je.Status == JournalEntryStatus.Draft)
{
<span class="badge bg-warning text-dark">Draft</span>
}
else if (je.Status == JournalEntryStatus.Posted)
{
<span class="badge bg-success">Posted</span>
}
else
{
<span class="badge bg-secondary">Reversed</span>
}
</span>
</div>
@if (!string.IsNullOrWhiteSpace(je.Description))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Description</span>
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@je.Description</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(je.Reference))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Reference</span>
<span class="mobile-card-value">@je.Reference</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
+117 -14
View File
@@ -5,7 +5,7 @@
string activeFilter = ViewBag.ActiveFilter as string ?? "all";
}
<div class="container-fluid px-4">
<div>
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-clipboard-check fs-3 text-primary"></i>
@@ -53,17 +53,116 @@
else
{
<div class="card">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var s in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-clipboard-check"></i>
</div>
<div class="mobile-card-title">
<h6>@s.CustomerFullName</h6>
<small>@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
{
<span class="badge bg-success">Converted</span>
}
else if (s.Status == KioskSessionStatus.Submitted)
{
<span class="badge bg-info text-dark">Submitted</span>
}
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
{
<span class="badge bg-warning text-dark">In Progress</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
@if (s.SessionType == KioskSessionType.InPerson)
{
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-tablet me-1"></i>In-Person</span>
}
else
{
<span class="badge" style="background:#ede9fe;color:#6d28d9;"><i class="bi bi-envelope me-1"></i>Remote</span>
}
</span>
</div>
@if (!string.IsNullOrEmpty(s.CustomerPhone))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Phone</span>
<span class="mobile-card-value"><a href="tel:@s.CustomerPhone">@s.CustomerPhone</a></span>
</div>
}
@if (!string.IsNullOrEmpty(s.CustomerEmail))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Email</span>
<span class="mobile-card-value" style="white-space:normal;"><a href="mailto:@s.CustomerEmail">@s.CustomerEmail</a></span>
</div>
}
@if (s.LinkedCustomerId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Matched</span>
<span class="mobile-card-value">
<a href="/Customers/Details/@s.LinkedCustomerId" class="text-success">
<i class="bi bi-person-check me-1"></i>Customer record
</a>
</span>
</div>
}
</div>
<div class="mobile-card-footer">
@if (s.LinkedJobId.HasValue)
{
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success">
<i class="bi bi-briefcase me-1"></i>Job
</a>
}
@if (s.LinkedQuoteId.HasValue)
{
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info">
<i class="bi bi-file-earmark-text me-1"></i>Quote
</a>
}
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th class="d-none d-md-table-cell">Date</th>
<th>Customer</th>
<th>Contact</th>
<th>Project</th>
<th>Type</th>
<th class="d-none d-lg-table-cell">Contact</th>
<th class="d-none d-lg-table-cell">Project</th>
<th class="d-none d-sm-table-cell">Type</th>
<th>Status</th>
<th>SMS</th>
<th class="d-none d-md-table-cell">SMS</th>
<th>Actions</th>
</tr>
</thead>
@@ -71,7 +170,7 @@
@foreach (var s in Model)
{
<tr>
<td class="text-nowrap text-muted small">
<td class="text-nowrap text-muted small d-none d-md-table-cell">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</td>
<td>
@@ -82,8 +181,12 @@
<i class="bi bi-person-check me-1"></i>Customer matched
</a>
}
@* Show date inline on mobile since the Date column is hidden *@
<div class="text-muted small d-md-none">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</div>
</td>
<td class="small text-muted">
<td class="small text-muted d-none d-lg-table-cell">
@if (!string.IsNullOrEmpty(s.CustomerPhone))
{
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
@@ -93,11 +196,11 @@
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
}
</td>
<td style="max-width:280px;">
<td class="d-none d-lg-table-cell" style="max-width:280px;">
<span class="text-truncate d-block" style="max-width:260px;"
title="@s.JobDescription">@s.JobDescriptionSnippet</span>
</td>
<td>
<td class="d-none d-sm-table-cell">
@if (s.SessionType == KioskSessionType.InPerson)
{
<span class="badge bg-primary-subtle text-primary">
@@ -129,7 +232,7 @@
<span class="badge bg-secondary">Expired</span>
}
</td>
<td>
<td class="d-none d-md-table-cell">
@if (s.SmsOptIn)
{
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
@@ -143,19 +246,19 @@
@if (s.LinkedJobId.HasValue)
{
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
<i class="bi bi-briefcase me-1"></i>View Job
<i class="bi bi-briefcase me-1"></i><span class="d-none d-sm-inline">View Job</span><span class="d-sm-none">Job</span>
</a>
}
@if (s.LinkedQuoteId.HasValue)
{
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
<i class="bi bi-file-earmark-text me-1"></i>View Quote
<i class="bi bi-file-earmark-text me-1"></i><span class="d-none d-sm-inline">View Quote</span><span class="d-sm-none">Quote</span>
</a>
}
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer
<i class="bi bi-person me-1"></i><span class="d-none d-sm-inline">Customer</span>
</a>
}
</td>
@@ -134,6 +134,67 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" : "linear-gradient(135deg, #06b6d4 0%, #0e7490 100%)");">
<i class="bi @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "bi-envelope" : "bi-phone")"></i>
</div>
<div class="mobile-card-title">
<h6>@item.RecipientName</h6>
<small>@item.Recipient</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">@item.NotificationTypeDisplay</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Sent</span>
<span class="mobile-card-value">@item.SentAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm")</span>
</div>
@if (item.JobId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Job</span>
<span class="mobile-card-value">@item.JobNumber</span>
</div>
}
else if (item.QuoteId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Quote</span>
<span class="mobile-card-value">@item.QuoteNumber</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@{
var (mStatusBadge, mStatusIcon) = item.Status switch
{
PowderCoating.Core.Enums.NotificationStatus.Sent => ("bg-success", "bi-check-circle"),
PowderCoating.Core.Enums.NotificationStatus.Failed => ("bg-danger", "bi-x-circle"),
_ => ("bg-secondary", "bi-dash-circle")
};
}
<span class="badge @mStatusBadge"><i class="bi @mStatusIcon me-1"></i>@item.StatusDisplay</span>
</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -44,6 +44,91 @@
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
var oPct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Companies", new { id = row.CompanyId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-building"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CompanyName</h6>
<small>
@switch (row.Status)
{
case OnboardingStatus.Complete:
<span class="badge bg-success">Complete</span>
break;
case OnboardingStatus.InProgress:
<span class="badge bg-warning text-dark">In Progress</span>
break;
case OnboardingStatus.Dismissed:
<span class="badge bg-secondary">Dismissed</span>
break;
default:
<span class="badge bg-light text-muted border">Not Started</span>
break;
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Wizard</span>
<span class="mobile-card-value">
@if (row.WizardCompleted)
{
<i class="bi bi-check-circle-fill text-success"></i>
<span class="text-success ms-1">Done</span>
}
else
{
<i class="bi bi-circle text-muted"></i>
<span class="text-muted ms-1">Pending</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Milestones</span>
<span class="mobile-card-value">
<div class="d-flex align-items-center gap-2">
<div class="progress" style="height:5px; width:60px;">
<div class="progress-bar @(oPct == 100 ? "bg-success" : "bg-primary")" style="width:@oPct%"></div>
</div>
<small class="text-muted">@row.StepsCompleted/@row.TotalSteps</small>
</div>
</span>
</div>
@{
var oFirstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
}
@if (oFirstActivity.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">First Activity</span>
<span class="mobile-card-value text-muted">@oFirstActivity.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-building me-1"></i>View
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-building fs-1 d-block mb-2 opacity-25"></i>
No companies found.
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="onboardingTable">
<thead class="table-light">
@@ -164,6 +164,56 @@
<!-- Grid -->
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var po in Model.Items)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = po.Id })'">
<div class="mobile-card-header" style="@(po.IsOverdue ? "background:#fee2e2;" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
<i class="bi bi-cart-check"></i>
</div>
<div class="mobile-card-title">
<h6>@po.PoNumber @(po.IsOverdue ? " — Overdue" : "")</h6>
<small>@po.VendorName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-@StatusBadge(po.Status)">@po.Status</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Order Date</span>
<span class="mobile-card-value">@po.OrderDate.ToString("MM/dd/yy")</span>
</div>
@if (po.ExpectedDeliveryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expected</span>
<span class="mobile-card-value @(po.IsOverdue ? "text-danger fw-semibold" : "")">
@po.ExpectedDeliveryDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Items</span>
<span class="mobile-card-value">@po.ItemCount</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value fw-semibold">$@po.TotalAmount.ToString("N2")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@po.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -604,6 +604,8 @@
if (taxField) {
taxField.value = exemptIds.has(customerId) ? 0 : (meta.companyTaxPercent ?? meta.taxPercent);
}
// Recalculate the live preview so it reflects the updated tax rate immediately
if (typeof scheduleAutoPricing === 'function') scheduleAutoPricing();
const noEmail = customerId > 0 && noEmailIds.has(customerId);
const emailSection = document.getElementById('emailNotifySection');
@@ -38,6 +38,96 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var t in Model)
{
var isOverdueRT = t.IsActive && t.NextFireDate.Date < DateTime.Today;
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);">
<i class="bi bi-arrow-repeat"></i>
</div>
<div class="mobile-card-title">
<h6>@t.Name</h6>
<small>
@if (t.TemplateType == RecurringTemplateType.Bill)
{
<span>Bill</span>
}
else
{
<span>Expense</span>
}
&mdash;
@(t.IntervalCount == 1 ? t.Frequency.ToString() : $"Every {t.IntervalCount} &times; {t.Frequency}")
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (t.IsActive)
{
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
}
else
{
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
}
</span>
</div>
@if (t.IsActive)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Next Fire</span>
<span class="mobile-card-value @(isOverdueRT ? "text-danger fw-semibold" : "")">
@t.NextFireDate.ToString("MM/dd/yyyy")
@if (isOverdueRT) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Occurrences</span>
<span class="mobile-card-value">
@t.OccurrenceCount
@if (t.MaxOccurrences.HasValue) { <span class="text-muted"> / @t.MaxOccurrences</span> }
</span>
</div>
@if (!string.IsNullOrWhiteSpace(t.LastError))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Error</span>
<span class="mobile-card-value text-danger small">@t.LastError</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post" style="display:inline;">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")">
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
</button>
</form>
@if (t.IsActive)
{
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post" style="display:inline;"
onsubmit="return confirm('Generate one occurrence now?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-lightning-charge"></i>
</button>
</form>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
@@ -65,6 +65,77 @@
</div>
<div class="card border-0 shadow-sm">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var note in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);">
<i class="bi bi-journal-text"></i>
</div>
<div class="mobile-card-title">
<h6><code>v@(note.Version)</code> &mdash; @note.Title</h6>
<small>
<span class="badge @TagBadge(note.Tag)">@note.Tag</span>
&nbsp;
@if (note.IsPublished)
{
<span class="badge bg-success">Published</span>
}
else
{
<span class="badge bg-warning text-dark">Draft</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Released</span>
<span class="mobile-card-value text-muted">@note.ReleasedAt.ToString("MM/dd/yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Created By</span>
<span class="mobile-card-value text-muted">@note.CreatedByUserName</span>
</div>
@if (note.Body.Length > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Preview</span>
<span class="mobile-card-value text-muted">@(note.Body.Length > 60 ? note.Body[..60] + "…" : note.Body)</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@note.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="TogglePublish" asp-route-id="@note.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(note.IsPublished ? "btn-outline-warning" : "btn-outline-success")">
<i class="bi @(note.IsPublished ? "bi-eye-slash" : "bi-eye")"></i>
</button>
</form>
<form asp-action="Delete" asp-route-id="@note.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete v@(note.Version)?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-journal-x fs-1 d-block mb-2 opacity-25"></i>
No release notes yet. <a asp-action="Create">Create the first one.</a>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 small">
<thead class="table-light">
@@ -112,6 +112,101 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-building"></i>
</div>
<div class="mobile-card-title">
<h6>
@row.CompanyName
@if (row.IsDeleted) { <span class="badge bg-secondary ms-1">Deleted</span> }
</h6>
<small>
@if (row.SmsDisabledByAdmin)
{
<span class="text-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
}
else if (row.SmsEnabled)
{
<span class="text-success"><i class="bi bi-chat-dots me-1"></i>SMS Enabled</span>
}
else
{
<span class="text-muted">SMS Off</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Terms</span>
<span class="mobile-card-value">
@{
var dispAgreement = row.CurrentAgreement ?? row.LatestAgreement;
}
@if (row.CurrentAgreement != null)
{
<span class="badge bg-success">v@(row.CurrentAgreement.TermsVersion)</span>
}
else if (row.LatestAgreement != null)
{
<span class="badge bg-warning text-dark">Stale (v@(row.LatestAgreement.TermsVersion))</span>
}
else
{
<span class="badge bg-light text-muted border">Never</span>
}
</span>
</div>
@if (dispAgreement != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Accepted By</span>
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
@dispAgreement.AgreedByUserName
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Accepted At</span>
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
@dispAgreement.AgreedAt.ToString("MM/dd/yy")
</span>
</div>
}
@if (row.AllAgreements.Count > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">History</span>
<span class="mobile-card-value">
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#historyModal"
data-company="@row.CompanyName"
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
a.TermsVersion,
a.AgreedByUserName,
a.AgreedByUserId,
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
IpAddress = a.IpAddress ?? "—",
UserAgent = a.UserAgent ?? "—"
}), new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase })"
onclick="event.stopPropagation()">
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
</button>
</span>
</div>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -110,6 +110,64 @@
</form>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Customers", new { id = row.CustomerId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #ec4899 0%, #be185d 100%);">
<i class="bi bi-phone-vibrate"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CustomerName</h6>
<small>@(row.MobilePhone ?? row.Phone ?? "No phone")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">SMS Status</span>
<span class="mobile-card-value"><span class="badge @row.StatusBadgeClass">@row.StatusLabel</span></span>
</div>
@if (row.ConsentedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Consented</span>
<span class="mobile-card-value">@row.ConsentedAt.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(row.ConsentMethod))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Method</span>
<span class="mobile-card-value">@row.ConsentMethod</span>
</div>
}
@if (row.OptedOutAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Opted Out</span>
<span class="mobile-card-value text-danger">@row.OptedOutAt.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@row.CustomerId"
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-person me-1"></i>Customer
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-phone-vibrate fs-1 d-block mb-2 opacity-25"></i>
No customers match the current filter.
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
@@ -84,6 +84,46 @@
}
</div>
</div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var file in Model.Files.OrderBy(f => f.Status).ThenBy(f => f.RelativePath))
{
var fStatusBadge = file.Status switch
{
MigrationFileStatus.Migrated => "bg-success",
MigrationFileStatus.Skipped => "bg-secondary",
_ => "bg-danger"
};
var fStatusLabel = file.Status switch
{
MigrationFileStatus.Migrated => "Migrated",
MigrationFileStatus.Skipped => "Already in Azure",
_ => "Failed"
};
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0369a1 0%, #075985 100%);">
<i class="bi bi-file-earmark"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace" style="font-size:.75rem;">@file.RelativePath</h6>
<small><span class="badge bg-light text-dark border">@file.Container</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Size</span>
<span class="mobile-card-value text-muted">@FormatBytes(file.FileSize)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @fStatusBadge">@fStatusLabel</span></span>
</div>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
@@ -68,6 +68,64 @@
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var vc in Model)
{
var (vcBadge, vcLabel) = vc.Status switch
{
VendorCreditStatus.Open => ("bg-success", "Open"),
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partial"),
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", vc.Status.ToString())
};
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = vc.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<i class="bi bi-credit-card"></i>
</div>
<div class="mobile-card-title">
<h6>@vc.CreditNumber</h6>
<small>@vc.Vendor?.CompanyName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @vcBadge">@vcLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Date</span>
<span class="mobile-card-value">@vc.CreditDate.ToString("MM/dd/yy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value">@vc.Total.ToString("C")</span>
</div>
<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")">
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "&mdash;")
</span>
</div>
@if (!string.IsNullOrWhiteSpace(vc.Memo))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Memo</span>
<span class="mobile-card-value">@vc.Memo</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@vc.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -9,8 +9,9 @@
}
@media (max-width: 991px) {
/* Hide desktop table view on mobile */
.table-responsive {
/* Hide desktop table only when a mobile card view sibling is present */
.mobile-card-view ~ .table-responsive,
.table-responsive:has(~ .mobile-card-view) {
display: none !important;
}
@@ -0,0 +1,37 @@
(function () {
var qtyInput = document.getElementById('Quantity');
var amtInput = document.getElementById('Amount');
var preview = document.getElementById('batchPreview');
var prevQty = document.getElementById('prevQty');
var prevAmt = document.getElementById('prevAmt');
var prevTotal = document.getElementById('prevTotal');
function updatePreview() {
var qty = parseInt(qtyInput.value, 10);
var amt = parseFloat(amtInput.value);
if (qty > 0 && amt > 0) {
prevQty.textContent = qty;
prevAmt.textContent = '$' + amt.toFixed(2);
prevTotal.textContent = '$' + (qty * amt).toFixed(2);
preview.style.display = '';
} else {
preview.style.display = 'none';
}
}
if (qtyInput && amtInput) {
qtyInput.addEventListener('input', updatePreview);
amtInput.addEventListener('input', updatePreview);
updatePreview();
}
// Disable submit button after first click to prevent double-submit during long creation
var form = document.querySelector('form');
var submitBtn = document.getElementById('submitBtn');
if (form && submitBtn) {
form.addEventListener('submit', function () {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
});
}
}());
@@ -2904,7 +2904,8 @@ async function runAutoPricing() {
try {
// Collect current form meta
const customerId = parseInt(document.querySelector('[name="CustomerId"]')?.value) || null;
const taxPercent = parseFloat(document.querySelector('[name="TaxPercent"]')?.value) || pageMeta.taxPercent || 0;
const _taxField = document.querySelector('[name="TaxPercent"]');
const taxPercent = _taxField ? parseFloat(_taxField.value) : (pageMeta.taxPercent ?? 0);
const discountType = document.getElementById('discountTypeSelect')?.value || 'None';
const discountVal = parseFloat(document.getElementById('discountValueInput')?.value) || 0;
const isRushJob = document.getElementById('IsRushJob')?.checked || false;
@@ -59,7 +59,8 @@ public class JobItemAssemblyServiceTests
var pricing = new QuoteItemPricingResult
{
UnitPrice = 29.99m,
TotalPrice = 59.98m
TotalPrice = 59.98m,
LaborCost = 23.992m // explicitly from pricing engine, not a 0.4× multiplier
};
var jobItem = _service.CreateJobItem(source, jobId: 10, companyId: 3, pricing: pricing, createdAtUtc: CreatedAtUtc);
@@ -0,0 +1,576 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
/// <summary>
/// Verifies that quantities, prices, overrides, and charges move correctly through all three
/// pricing stages: Quote → Job → Invoice. Each test targets one transition or cross-cutting concern.
/// </summary>
public class PricingStageFlowTests
{
// ─── Stage 1: QuotePricingAssemblyService.ApplyPricingSnapshot ───────────────
[Fact]
public void ApplyPricingSnapshot_StoresAllNewBreakdownFields()
{
// FacilityOverheadCost, FacilityOverheadRatePerHour, PricingTierDiscount, QuoteDiscount,
// and SubtotalAfterDiscount were added in a recent migration. Verify they are all stored.
var service = CreateAssemblyService(CreateContext());
var quote = new Quote();
var pricing = new QuotePricingResult
{
FacilityOverheadCost = 12.50m,
FacilityOverheadRatePerHour = 25m,
PricingTierDiscountAmount = 5m,
PricingTierDiscountPercent = 2m,
QuoteDiscountAmount = 10m,
QuoteDiscountPercent = 4m,
DiscountAmount = 15m,
DiscountPercent = 6m,
SubtotalAfterDiscount = 235m,
RushFee = 20m,
TaxAmount = 23.5m,
Total = 278.50m,
SubtotalBeforeDiscount = 250m,
ItemsSubtotal = 200m,
OvenBatchCost = 18m,
ShopSuppliesAmount = 8m,
ShopSuppliesPercent = 4m
};
service.ApplyPricingSnapshot(quote, pricing);
Assert.Equal(12.50m, quote.FacilityOverheadCost, precision: 2);
Assert.Equal(25m, quote.FacilityOverheadRatePerHour, precision: 2);
Assert.Equal(5m, quote.PricingTierDiscountAmount, precision: 2);
Assert.Equal(2m, quote.PricingTierDiscountPercent, precision: 2);
Assert.Equal(10m, quote.QuoteDiscountAmount, precision: 2);
Assert.Equal(4m, quote.QuoteDiscountPercent, precision: 2);
Assert.Equal(15m, quote.DiscountAmount, precision: 2);
Assert.Equal(6m, quote.DiscountPercent, precision: 2);
Assert.Equal(235m, quote.SubtotalAfterDiscount, precision: 2);
Assert.Equal(20m, quote.RushFee, precision: 2);
Assert.Equal(23.5m, quote.TaxAmount, precision: 2);
Assert.Equal(278.50m, quote.Total, precision: 2);
}
// ─── Stage 2: Quote → Job (QuotesController.UpdateQuoteStatus) ────────────────
[Fact]
public async Task QuoteToJob_PricingSnapshotCarriesAllCharges()
{
// Verifies that OvenBatchCost, FacilityOverheadCost, ShopSuppliesAmount, RushFee,
// and all discount fields from the approved quote land in Job.PricingBreakdownJson.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
var result = await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest
{
QuoteId = 1,
StatusId = approvedStatusId
});
Assert.IsType<JsonResult>(result);
var job = await context.Jobs.SingleAsync();
Assert.NotNull(job.PricingBreakdownJson);
var breakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson!);
Assert.NotNull(breakdown);
Assert.Equal(150m, breakdown.ItemsSubtotal, precision: 2);
Assert.Equal(18m, breakdown.OvenBatchCost, precision: 2);
Assert.Equal(12m, breakdown.FacilityOverheadCost, precision: 2);
Assert.Equal(6m, breakdown.ShopSuppliesAmount, precision: 2);
Assert.Equal(25m, breakdown.RushFee, precision: 2);
Assert.Equal(15m, breakdown.DiscountAmount, precision: 2);
Assert.Equal(211m, breakdown.Total, precision: 2);
}
[Fact]
public async Task QuoteToJob_ItemPricesAndOverridesTransfer()
{
// Verifies that UnitPrice, TotalPrice, ManualUnitPrice, PowderCostOverride,
// CatalogItemId, and Notes all survive the quote→job item conversion.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
var jobItem = await context.JobItems.SingleAsync();
Assert.Equal(75m, jobItem.UnitPrice, precision: 2);
Assert.Equal(150m, jobItem.TotalPrice, precision: 2);
Assert.Equal(69m, jobItem.ManualUnitPrice);
Assert.Equal(8.50m, jobItem.PowderCostOverride);
Assert.Equal(99, jobItem.CatalogItemId);
Assert.Equal("Handle carefully — thin walls", jobItem.Notes);
}
[Fact]
public async Task QuoteToJob_CoatInventoryIdAndPowderToOrderTransfer()
{
// InventoryItemId on coats gates the powder charging logic in PricingCalculationService.
// PowderToOrder is the purchase quantity — both must survive quote→job conversion.
await using var context = CreateContext();
SeedQuoteWithFullPricing(context);
await context.SaveChangesAsync();
var controller = CreateQuotesController(context);
var approvedStatusId = context.QuoteStatusLookups.Single(s => s.StatusCode == "APPROVED").Id;
await controller.UpdateQuoteStatus(new UpdateQuoteStatusRequest { QuoteId = 1, StatusId = approvedStatusId });
var coat = await context.JobItemCoats.SingleAsync();
Assert.Equal(50, coat.InventoryItemId);
Assert.Equal(2.0m, coat.PowderToOrder);
Assert.Equal(4.50m, coat.PowderCostPerLb);
}
// ─── Stage 3: Job → Invoice (InvoicesController.Create GET with jobId) ──────────
[Fact]
public async Task JobToInvoice_ItemFieldsPopulateCorrectly()
{
// Notes and CatalogItemId on JobItem must reach InvoiceItem.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: false);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var item = dto.InvoiceItems.First(i => i.SourceJobItemId.HasValue);
Assert.Equal(3m, item.Quantity);
Assert.Equal(45m, item.UnitPrice, precision: 2);
Assert.Equal(135m, item.TotalPrice, precision: 2);
Assert.Equal("Gloss Black", item.ColorName);
Assert.Equal(99, item.CatalogItemId);
Assert.Equal("Watch corners — mask before blasting", item.Notes);
}
[Fact]
public async Task JobToInvoice_DirectJob_AddsOvenShopSuppliesRushFeeLines()
{
// A job created directly (no source quote) must invoice all three processing charges
// separately, reading RushFee and FacilityOverheadCost from PricingBreakdownJson.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: false);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var descriptions = dto.InvoiceItems.Select(i => i.Description).ToList();
Assert.Contains("Oven Processing Fee", descriptions);
Assert.Contains("Facility Overhead", descriptions);
Assert.Contains("Shop Supplies (4%)", descriptions);
Assert.Contains("Rush Fee", descriptions);
var oven = dto.InvoiceItems.Single(i => i.Description == "Oven Processing Fee");
var overhead = dto.InvoiceItems.Single(i => i.Description == "Facility Overhead");
var shop = dto.InvoiceItems.Single(i => i.Description == "Shop Supplies (4%)");
var rush = dto.InvoiceItems.Single(i => i.Description == "Rush Fee");
Assert.Equal(18m, oven.TotalPrice, precision: 2);
Assert.Equal(12m, overhead.TotalPrice, precision: 2);
Assert.Equal(6m, shop.TotalPrice, precision: 2);
Assert.Equal(25m, rush.TotalPrice, precision: 2);
}
[Fact]
public async Task JobToInvoice_FromQuote_BundlesAllProcessingFeesIncludingFacilityOverhead()
{
// When a job came from a quote, all processing charges must be bundled as one line,
// including FacilityOverheadCost which was previously missing.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: true);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
var processingLine = dto.InvoiceItems.SingleOrDefault(i => i.Description == "Oven & Shop Processing Fees");
Assert.NotNull(processingLine);
// OvenBatchCost(18) + FacilityOverheadCost(12) + ShopSuppliesAmount(6) + RushFee(25) = 61
Assert.Equal(61m, processingLine!.TotalPrice, precision: 2);
}
[Fact]
public async Task JobToInvoice_TaxAndDiscountFromQuoteNotRecomputed()
{
// Invoice must carry the agreed quote TaxPercent and DiscountAmount,
// not re-derive from current company defaults.
await using var context = CreateContext();
SeedJobForInvoicing(context, hasSourceQuote: true);
await context.SaveChangesAsync();
var controller = CreateInvoicesController(context);
var result = await controller.Create(jobId: 1) as ViewResult;
Assert.NotNull(result);
var dto = Assert.IsType<CreateInvoiceDto>(result.Model);
Assert.Equal(8.5m, dto.TaxPercent, precision: 2);
Assert.Equal(15m, dto.DiscountAmount, precision: 2);
}
// ─── JobItemAssemblyService: Notes field ──────────────────────────────────────
[Fact]
public void CreateJobItem_FromDto_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var dto = new CreateQuoteItemDto { Description = "Part", Notes = "Fragile — no drop" };
var pricing = new QuoteItemPricingResult { UnitPrice = 10m, TotalPrice = 10m };
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
Assert.Equal("Fragile — no drop", item.Notes);
}
[Fact]
public void CreateJobItem_FromQuoteItem_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var quoteItem = new QuoteItem { Description = "Part", Notes = "Do not sandblast" };
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
Assert.Equal("Do not sandblast", item.Notes);
}
[Fact]
public void CreateJobItem_FromJobItem_PreservesNotes()
{
var svc = new JobItemAssemblyService();
var source = new JobItem { Description = "Part", Notes = "Carry-over note", LaborCost = 0m };
var item = svc.CreateJobItem(source, jobId: 2, companyId: 1, DateTime.UtcNow);
Assert.Equal("Carry-over note", item.Notes);
}
// ─── LaborCost: must come from pricing engine, not a hardcoded multiplier ─────
[Fact]
public void CreateJobItem_FromDto_UsesLaborCostFromPricingResult()
{
var svc = new JobItemAssemblyService();
var dto = new CreateQuoteItemDto { Description = "Rail" };
var pricing = new QuoteItemPricingResult { UnitPrice = 100m, TotalPrice = 200m, LaborCost = 55m };
var item = svc.CreateJobItem(dto, jobId: 1, companyId: 1, pricing, DateTime.UtcNow);
Assert.Equal(55m, item.LaborCost, precision: 2);
}
[Fact]
public void CreateJobItem_FromQuoteItem_UsesStoredItemLaborCost()
{
var svc = new JobItemAssemblyService();
var quoteItem = new QuoteItem
{
Description = "Rail",
UnitPrice = 100m,
TotalPrice = 200m,
ItemLaborCost = 62m
};
var item = svc.CreateJobItem(quoteItem, jobId: 1, companyId: 1, DateTime.UtcNow);
Assert.Equal(62m, item.LaborCost, precision: 2);
}
// ─── Seed helpers ─────────────────────────────────────────────────────────────
private static void SeedQuoteWithFullPricing(ApplicationDbContext context)
{
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
context.InventoryItems.Add(new InventoryItem
{
Id = 50, CompanyId = 1, SKU = "BLK-1", Name = "Gloss Black",
ColorCode = "RAL9005", Finish = "Gloss", Category = "Powder", UnitOfMeasure = "lbs"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup { Id = 1, CompanyId = 1, StatusCode = "DRAFT", DisplayName = "Draft" },
new QuoteStatusLookup { Id = 2, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" },
new QuoteStatusLookup { Id = 3, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
context.JobStatusLookups.Add(new JobStatusLookup
{ Id = 10, CompanyId = 1, StatusCode = "APPROVED", DisplayName = "Approved" });
context.JobPriorityLookups.AddRange(
new JobPriorityLookup { Id = 20, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" },
new JobPriorityLookup { Id = 21, CompanyId = 1, PriorityCode = "RUSH", DisplayName = "Rush" });
context.PrepServices.Add(new PrepService
{ Id = 5, CompanyId = 1, ServiceName = "Sandblast", DisplayOrder = 1, IsActive = true });
context.Quotes.Add(new Quote
{
Id = 1, CompanyId = 1, QuoteNumber = "Q-2601-0001", CustomerId = 1, QuoteStatusId = 1,
IsRushJob = true,
ItemsSubtotal = 150m,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
DiscountAmount = 15m,
DiscountPercent = 6m,
SubtotalAfterDiscount = 196m,
TaxPercent = 8.5m,
TaxAmount = 16.66m,
Total = 211m
});
context.QuoteItems.Add(new QuoteItem
{
Id = 100, QuoteId = 1, CompanyId = 1,
Description = "Powder coat rail",
Quantity = 2m,
SurfaceAreaSqFt = 20m,
CatalogItemId = 99,
IsSalesItem = false,
ManualUnitPrice = 69m,
PowderCostOverride = 8.50m,
UnitPrice = 75m,
TotalPrice = 150m,
ItemLaborCost = 40m,
Notes = "Handle carefully — thin walls",
IncludePrepCost = true,
EstimatedMinutes = 30
});
context.QuoteItemCoats.Add(new QuoteItemCoat
{
Id = 101, QuoteItemId = 100, CompanyId = 1,
CoatName = "Base Coat", Sequence = 1,
InventoryItemId = 50,
ColorName = "Old Name",
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
PowderCostPerLb = 4.50m,
PowderToOrder = 2.0m
});
context.QuoteItemPrepServices.Add(new QuoteItemPrepService
{ Id = 102, QuoteItemId = 100, CompanyId = 1, PrepServiceId = 5, EstimatedMinutes = 10 });
}
private static void SeedJobForInvoicing(ApplicationDbContext context, bool hasSourceQuote)
{
context.Customers.Add(new Customer { Id = 1, CompanyId = 1, CompanyName = "Test Co" });
context.JobStatusLookups.Add(new JobStatusLookup
{ Id = 1, CompanyId = 1, StatusCode = "COMPLETED", DisplayName = "Completed" });
context.JobPriorityLookups.Add(new JobPriorityLookup
{ Id = 1, CompanyId = 1, PriorityCode = "NORMAL", DisplayName = "Normal" });
// Serialized breakdown carrying FacilityOverheadCost and RushFee
var breakdown = new QuotePricingBreakdownDto
{
ItemsSubtotal = 135m,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
TaxPercent = 8.5m,
Total = 211m
};
Quote? quote = null;
if (hasSourceQuote)
{
quote = new Quote
{
Id = 1, CompanyId = 1, QuoteNumber = "Q-TEST", CustomerId = 1,
QuoteStatusId = 1,
OvenBatchCost = 18m,
FacilityOverheadCost = 12m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
RushFee = 25m,
DiscountAmount = 15m,
TaxPercent = 8.5m,
Total = 211m
};
context.QuoteStatusLookups.Add(new QuoteStatusLookup
{ Id = 1, CompanyId = 1, StatusCode = "CONVERTED", DisplayName = "Converted" });
context.Quotes.Add(quote);
}
context.Jobs.Add(new Job
{
Id = 1, CompanyId = 1, JobNumber = "JOB-TEST", CustomerId = 1,
Description = "Test job",
JobStatusId = 1,
JobPriorityId = 1,
QuoteId = hasSourceQuote ? 1 : null,
OvenBatchCost = 18m,
ShopSuppliesAmount = 6m,
ShopSuppliesPercent = 4m,
IsRushJob = true,
FinalPrice = 211m,
PricingBreakdownJson = JsonSerializer.Serialize(breakdown)
});
context.JobItems.Add(new JobItem
{
Id = 10, JobId = 1, CompanyId = 1,
Description = "Powder coat wheel",
Quantity = 3m,
UnitPrice = 45m,
TotalPrice = 135m,
ColorName = "Gloss Black",
CatalogItemId = 99,
Notes = "Watch corners — mask before blasting",
EstimatedMinutes = 20,
LaborCost = 30m
});
}
// ─── Controller / service factory helpers ────────────────────────────────────
private static QuotePricingAssemblyService CreateAssemblyService(ApplicationDbContext context) =>
new(new UnitOfWork(context),
Mock.Of<IPricingCalculationService>(),
Mock.Of<IInventoryAiLookupService>(),
Mock.Of<ILogger<QuotePricingAssemblyService>>());
private static QuotesController CreateQuotesController(ApplicationDbContext context)
{
var lookupCache = new Mock<ILookupCacheService>();
lookupCache.Setup(x => x.GetQuoteStatusLookupsAsync(It.IsAny<int>()))
.ReturnsAsync(() => context.QuoteStatusLookups.ToList());
return new QuotesController(
new UnitOfWork(context),
Mock.Of<AutoMapper.IMapper>(),
Mock.Of<IPricingCalculationService>(),
CreateUserManager().Object,
Mock.Of<ILogger<QuotesController>>(),
Mock.Of<IPdfService>(),
CreateTenantContext().Object,
Mock.Of<IMeasurementConversionService>(),
lookupCache.Object,
Mock.Of<INotificationService>(),
Mock.Of<ISubscriptionService>(),
new JobItemAssemblyService(),
Mock.Of<IQuotePricingAssemblyService>(),
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<IPlatformSettingsService>(),
Mock.Of<IQuotePhotoService>(),
Mock.Of<IAiQuoteService>(),
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IJobPhotoService>(),
Mock.Of<IAiUsageLogger>(),
Mock.Of<ICompanyLogoService>(),
Mock.Of<IInventoryAiLookupService>());
}
private static InvoicesController CreateInvoicesController(ApplicationDbContext context)
{
var controller = new InvoicesController(
new UnitOfWork(context),
Mock.Of<AutoMapper.IMapper>(),
CreateUserManager().Object,
Mock.Of<ILogger<InvoicesController>>(),
Mock.Of<IPdfService>(),
CreateTenantContext().Object,
Mock.Of<INotificationService>(),
Mock.Of<IAccountBalanceService>(),
Mock.Of<ICompanyLogoService>());
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
var principal = new ClaimsPrincipal(identity);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = principal }
};
return controller;
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
var mgr = new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
mgr.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync(new ApplicationUser
{
Id = "user-1",
CompanyId = 1,
UserName = "testuser",
Email = "test@test.com"
});
return mgr;
}
private static Mock<ITenantContext> CreateTenantContext()
{
var tc = new Mock<ITenantContext>();
tc.Setup(x => x.GetCurrentCompanyId()).Returns(1);
tc.Setup(x => x.IsSuperAdmin()).Returns(true);
tc.Setup(x => x.IsPlatformAdmin()).Returns(true);
return tc;
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, "SuperAdmin")], "Test");
var principal = new ClaimsPrincipal(identity);
byte[]? noBytes = null;
var sessionMock = new Mock<ISession>();
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
var httpContextMock = new Mock<HttpContext>();
httpContextMock.SetupGet(c => c.User).Returns(principal);
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
return new ApplicationDbContext(options, accessor.Object, null!);
}
}