Merge dev into master
This commit is contained in:
@@ -171,7 +171,8 @@
|
||||
"PowerShell(Select-String *)",
|
||||
"Bash(Select-Object -First 20)",
|
||||
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)"
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)",
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
|
||||
+8
-4
@@ -1,9 +1,9 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
@@ -181,6 +181,10 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
|
||||
-When scanning inventory QR Code, there is no cancel button
|
||||
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
|
||||
-Add SMS capabilities
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
I looked through the reporting code, and I do not see a dedicated sales tax report.
|
||||
|
||||
What exists today:
|
||||
|
||||
- The report menu has Financial Summary, AR Aging, Balance Sheet, and Sales & Income, but no sales-tax-specific report card in /Y:/PCC/
|
||||
PowderCoatingApp/src/PowderCoating.Web/Views/Reports/Landing.cshtml:147 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/
|
||||
Landing.cshtml:211.
|
||||
- The reporting service only exposes four finance reports in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/
|
||||
IFinancialReportService.cs:13 through /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs:22.
|
||||
- Sales & Income does surface tax, but only as a total and per-invoice column in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/
|
||||
Services/FinancialReportService.cs:406, /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs:419,
|
||||
and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/SalesAndIncome.cshtml:268.
|
||||
- The good news is the underlying data is already there: invoices store TaxAmount and SalesTaxAccountId in /Y:/PCC/PowderCoatingApp/src/
|
||||
PowderCoating.Core/Entities/Invoice.cs:23 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/Invoice.cs:55, and the chart of
|
||||
accounts seeds 2200 Sales Tax Payable in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs:59.
|
||||
|
||||
Plan I’d recommend:
|
||||
|
||||
1. Add a new Sales Tax report under Reports > Finance with date range filters, matching the existing report pattern in /Y:/PCC/PowderCoatingApp/
|
||||
src/PowderCoating.Web/Controllers/ReportsController.cs:993.
|
||||
2. Build a SalesTaxReportDto plus GetSalesTaxReportAsync(...) in the reporting interface/service.
|
||||
3. Phase 1 report contents:
|
||||
|
||||
- Total taxable sales
|
||||
- Total non-taxed sales
|
||||
- Total sales tax billed
|
||||
- Breakdown by sales-tax liability account
|
||||
- Breakdown by month
|
||||
- Invoice detail grid: invoice date, invoice #, customer, subtotal, tax %, tax amount, total, amount paid, balance due, status, tax account
|
||||
|
||||
4. Add PDF export, and probably CSV too, since this is the kind of report people hand to accountants.
|
||||
5. Put a report card on the Reports landing page and gate it the same way as the other accounting reports.
|
||||
|
||||
Two important caveats before we build it:
|
||||
|
||||
- The biggest decision is whether this should be invoice-basis or cash-basis. The current model naturally supports invoice-basis first.
|
||||
- Refunds and credit memos do not appear to store a separate tax adjustment value in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/
|
||||
Refund.cs:1 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/CreditMemo.cs:1, so phase 1 would be best framed as “sales tax billed
|
||||
on invoices,” with net tax adjustments as a phase 2 enhancement.
|
||||
|
||||
My recommendation is:
|
||||
|
||||
- Build phase 1 as an invoice-basis Sales Tax Liability report
|
||||
- Label zero-tax invoices as non-taxed sales
|
||||
- Add cash-basis and refund/credit tax adjustments later if you need filing-grade accuracy for more complex cases
|
||||
|
||||
If you want, I can turn this plan into the actual report next.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,8 @@ public class SaveQuickQuoteRequest
|
||||
public decimal EstimatedUnitPrice { get; set; }
|
||||
public decimal MaterialCost { get; set; }
|
||||
public decimal LaborCost { get; set; }
|
||||
public decimal OvenBatchCost { get; set; }
|
||||
public int OvenCycleMinutes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema returned by Claude for quick quote analysis.</summary>
|
||||
|
||||
@@ -159,3 +159,65 @@ public class SalesInvoiceLineDto
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SALES TAX REPORT
|
||||
// ============================================================
|
||||
|
||||
public class SalesTaxReportDto
|
||||
{
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Subtotal of invoices where TaxAmount > 0.</summary>
|
||||
public decimal TotalTaxableSales { get; set; }
|
||||
/// <summary>Subtotal of invoices where TaxAmount == 0.</summary>
|
||||
public decimal TotalNonTaxableSales { get; set; }
|
||||
/// <summary>Sum of all TaxAmount values across the period.</summary>
|
||||
public decimal TotalTaxBilled { get; set; }
|
||||
public int TaxableInvoiceCount { get; set; }
|
||||
public int NonTaxableInvoiceCount { get; set; }
|
||||
public decimal EffectiveTaxRate => TotalTaxableSales == 0 ? 0
|
||||
: Math.Round(TotalTaxBilled / TotalTaxableSales * 100, 2);
|
||||
|
||||
public List<SalesTaxByAccountDto> ByAccount { get; set; } = new();
|
||||
public List<SalesTaxByMonthDto> ByMonth { get; set; } = new();
|
||||
public List<SalesTaxInvoiceLineDto> Invoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SalesTaxByAccountDto
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public decimal TaxableSales { get; set; }
|
||||
public decimal TaxBilled { get; set; }
|
||||
public int InvoiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class SalesTaxByMonthDto
|
||||
{
|
||||
public int Year { get; set; }
|
||||
public int Month { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public decimal TaxableSales { get; set; }
|
||||
public decimal TaxBilled { get; set; }
|
||||
public int InvoiceCount { get; set; }
|
||||
}
|
||||
|
||||
public class SalesTaxInvoiceLineDto
|
||||
{
|
||||
public int InvoiceId { get; set; }
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public decimal SubTotal { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string TaxAccountName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public class InventoryItemDto
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? ManufacturerPartNumber { get; set; }
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
public decimal? SpecificGravity { get; set; }
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
public int? CureTemperatureF { get; set; }
|
||||
public int? CureTimeMinutes { get; set; }
|
||||
@@ -125,6 +126,10 @@ public class CreateInventoryItemDto
|
||||
[Display(Name = "Coverage (Sq Ft/Lb)")]
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Specific gravity must be between 0 and 100")]
|
||||
[Display(Name = "Specific Gravity")]
|
||||
public decimal? SpecificGravity { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Transfer efficiency must be between 0 and 100%")]
|
||||
[Display(Name = "Transfer Efficiency (%)")]
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
|
||||
@@ -23,6 +23,7 @@ public class PowderCatalogLookupResult
|
||||
public string? ColorFamilies { get; set; }
|
||||
public bool? RequiresClearCoat { get; set; }
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
public decimal? SpecificGravity { get; set; }
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,7 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
|
||||
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public class InventoryAiLookupResult
|
||||
public bool? RequiresClearCoat { get; set; }
|
||||
|
||||
// Application properties
|
||||
public decimal? SpecificGravity { get; set; } // used to derive theoretical coverage when docs omit coverage
|
||||
public decimal? CoverageSqFtPerLb { get; set; } // typical ~80-120 sq ft/lb
|
||||
public decimal? TransferEfficiency { get; set; } // typical 50-75%
|
||||
public decimal? UnitCostPerLb { get; set; } // price per lb/unit if found in search results
|
||||
|
||||
@@ -41,6 +41,7 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.DTOs.GiftCertificate;
|
||||
using PowderCoating.Application.DTOs.Invoice;
|
||||
@@ -2122,4 +2123,202 @@ public class PdfService : IPdfService
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sales Tax Report ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Generates a letter-sized PDF for the Sales Tax Liability report. Sections: summary KPI
|
||||
/// row, breakdown by tax account, breakdown by month, then a full invoice detail table.
|
||||
/// Intended for handing to an accountant or attaching to a tax filing.
|
||||
/// </summary>
|
||||
public Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#1e3a5f";
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.65f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Column(col =>
|
||||
{
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text(dto.CompanyName).FontSize(14).Bold().FontColor(accent);
|
||||
c.Item().Text("Sales Tax Liability Report").FontSize(10).FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text($"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}").FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.RelativeItem().AlignRight().Column(c =>
|
||||
{
|
||||
c.Item().Text("Invoice-Basis Report").FontSize(8).Italic().FontColor(Colors.Grey.Medium);
|
||||
c.Item().Text($"Generated {DateTime.Today:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
});
|
||||
col.Item().PaddingTop(4).BorderBottom(1.5f).BorderColor(accent);
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
// KPI summary row
|
||||
col.Item().PaddingBottom(12).Row(row =>
|
||||
{
|
||||
void KpiBox(RowDescriptor r, string label, string value, string bg)
|
||||
{
|
||||
r.RelativeItem().Background(bg).Padding(8).Column(c =>
|
||||
{
|
||||
c.Item().Text(label).FontSize(7.5f).FontColor(Colors.Grey.Darken2);
|
||||
c.Item().Text(value).FontSize(13).Bold().FontColor(accent);
|
||||
});
|
||||
}
|
||||
KpiBox(row, "Total Tax Billed", $"{dto.TotalTaxBilled:C}", "#e8f4fd");
|
||||
row.ConstantItem(6);
|
||||
KpiBox(row, "Taxable Sales", $"{dto.TotalTaxableSales:C}", "#f0fdf4");
|
||||
row.ConstantItem(6);
|
||||
KpiBox(row, "Non-Taxable Sales", $"{dto.TotalNonTaxableSales:C}", "#fafafa");
|
||||
row.ConstantItem(6);
|
||||
KpiBox(row, "Effective Tax Rate", $"{dto.EffectiveTaxRate:F2}%", "#fff7ed");
|
||||
});
|
||||
|
||||
// By account
|
||||
if (dto.ByAccount.Any())
|
||||
{
|
||||
col.Item().PaddingBottom(4).Text("Tax by Liability Account").FontSize(10).Bold().FontColor(accent);
|
||||
col.Item().PaddingBottom(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(c =>
|
||||
{
|
||||
c.RelativeColumn(3);
|
||||
c.RelativeColumn(2);
|
||||
c.RelativeColumn(2);
|
||||
c.ConstantColumn(40);
|
||||
});
|
||||
void AHdr(string t) => table.Header(h => h.Cell().Background("#e8f4fd").Padding(4).Text(t).Bold().FontSize(8));
|
||||
table.Header(h =>
|
||||
{
|
||||
h.Cell().Background("#e8f4fd").Padding(4).Text("Account").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
|
||||
});
|
||||
foreach (var a in dto.ByAccount)
|
||||
{
|
||||
var label = string.IsNullOrEmpty(a.AccountNumber) ? a.AccountName : $"{a.AccountNumber} {a.AccountName}";
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(label).FontSize(8);
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxableSales:C}").FontSize(8);
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{a.TaxBilled:C}").FontSize(8).Bold();
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(a.InvoiceCount.ToString()).FontSize(8);
|
||||
}
|
||||
// Totals row
|
||||
table.Cell().Background("#f8fafc").Padding(4).Text("Total").Bold().FontSize(8);
|
||||
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxableSales):C}").Bold().FontSize(8);
|
||||
table.Cell().Background("#f8fafc").Padding(4).AlignRight().Text($"{dto.ByAccount.Sum(a => a.TaxBilled):C}").Bold().FontSize(8);
|
||||
table.Cell().Background("#f8fafc").Padding(4).AlignCenter().Text(dto.ByAccount.Sum(a => a.InvoiceCount).ToString()).Bold().FontSize(8);
|
||||
});
|
||||
}
|
||||
|
||||
// By month
|
||||
if (dto.ByMonth.Any())
|
||||
{
|
||||
col.Item().PaddingBottom(4).Text("Tax by Month").FontSize(10).Bold().FontColor(accent);
|
||||
col.Item().PaddingBottom(10).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(c =>
|
||||
{
|
||||
c.RelativeColumn(2);
|
||||
c.RelativeColumn(2);
|
||||
c.RelativeColumn(2);
|
||||
c.ConstantColumn(40);
|
||||
});
|
||||
table.Header(h =>
|
||||
{
|
||||
h.Cell().Background("#e8f4fd").Padding(4).Text("Month").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Taxable Sales").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignRight().Text("Tax Billed").Bold().FontSize(8);
|
||||
h.Cell().Background("#e8f4fd").Padding(4).AlignCenter().Text("Invoices").Bold().FontSize(8);
|
||||
});
|
||||
foreach (var m in dto.ByMonth)
|
||||
{
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).Text(m.Label).FontSize(8);
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxableSales:C}").FontSize(8);
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignRight().Text($"{m.TaxBilled:C}").FontSize(8).Bold();
|
||||
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(4).AlignCenter().Text(m.InvoiceCount.ToString()).FontSize(8);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Invoice detail
|
||||
col.Item().PaddingBottom(4).Text("Invoice Detail").FontSize(10).Bold().FontColor(accent);
|
||||
col.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(c =>
|
||||
{
|
||||
c.ConstantColumn(70); // Invoice #
|
||||
c.RelativeColumn(2.5f); // Customer
|
||||
c.ConstantColumn(58); // Date
|
||||
c.ConstantColumn(48); // Status
|
||||
c.ConstantColumn(52); // SubTotal
|
||||
c.ConstantColumn(34); // Tax %
|
||||
c.ConstantColumn(52); // Tax $
|
||||
c.ConstantColumn(52); // Total
|
||||
c.RelativeColumn(2); // Tax Account
|
||||
});
|
||||
table.Header(h =>
|
||||
{
|
||||
string bg = "#e8f4fd";
|
||||
h.Cell().Background(bg).Padding(3).Text("Invoice #").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).Text("Customer").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).Text("Date").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).Text("Status").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Subtotal").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax %").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Tax $").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).AlignRight().Text("Total").Bold().FontSize(7.5f);
|
||||
h.Cell().Background(bg).Padding(3).Text("Tax Account").Bold().FontSize(7.5f);
|
||||
});
|
||||
foreach (var inv in dto.Invoices)
|
||||
{
|
||||
var rowBg = inv.TaxAmount == 0 ? Colors.Grey.Lighten4 : Colors.White;
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceNumber).FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.CustomerName).FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.InvoiceDate.ToString("MM/dd/yyyy")).FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.Status).FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.SubTotal:C}").FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxPercent:F2}%" : "—").FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text(inv.TaxAmount > 0 ? $"{inv.TaxAmount:C}" : "—").FontSize(7.5f).Bold();
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).AlignRight().Text($"{inv.Total:C}").FontSize(7.5f);
|
||||
table.Cell().Background(rowBg).BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3).Text(inv.TaxAccountName).FontSize(7).FontColor(Colors.Grey.Darken1);
|
||||
}
|
||||
// Totals row
|
||||
table.Cell().ColumnSpan(4).Background("#f0fdf4").Padding(3).AlignRight().Text("Totals").Bold().FontSize(7.5f);
|
||||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.SubTotal):C}").Bold().FontSize(7.5f);
|
||||
table.Cell().Background("#f0fdf4").Padding(3);
|
||||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.TotalTaxBilled:C}").Bold().FontSize(7.5f);
|
||||
table.Cell().Background("#f0fdf4").Padding(3).AlignRight().Text($"{dto.Invoices.Sum(i => i.Total):C}").Bold().FontSize(7.5f);
|
||||
table.Cell().Background("#f0fdf4").Padding(3);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.DefaultTextStyle(s => s.FontSize(7.5f).FontColor(Colors.Grey.Medium));
|
||||
text.Span("Sales Tax Liability Report | Invoice Basis | ");
|
||||
text.CurrentPageNumber();
|
||||
text.Span(" of ");
|
||||
text.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,12 +422,14 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
else
|
||||
{
|
||||
// Non-catalog: derive base from first coat's material + labor + equipment + markup
|
||||
decimal coatLaborCost = 0m; // coat-only labor, used for coating booth (not prep/sandblast)
|
||||
if (item.Coats != null && item.Coats.Count > 0)
|
||||
{
|
||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
||||
totalLaborCost = firstCoatResult.CoatLaborCost;
|
||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||
totalLaborCost = coatLaborCost;
|
||||
}
|
||||
|
||||
// Prep service labor (done once per item batch)
|
||||
@@ -443,9 +445,10 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
// Consumables surcharge (5% of material)
|
||||
totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent;
|
||||
|
||||
// Equipment cost: coating booth only (oven cost moved to quote-level batch calculation)
|
||||
var totalLaborHours = totalLaborCost / costs.StandardLaborRate;
|
||||
totalEquipmentCost = totalLaborHours * costs.CoatingBoothCostPerHour;
|
||||
// Equipment cost: coating booth only — use coat labor hours, not prep/sandblast hours
|
||||
// (sandblasting happens in a blast cabinet, not the powder coating booth)
|
||||
var coatLaborHours = costs.StandardLaborRate > 0 ? coatLaborCost / costs.StandardLaborRate : 0m;
|
||||
totalEquipmentCost = coatLaborHours * costs.CoatingBoothCostPerHour;
|
||||
|
||||
// Apply pricing mode: markup on material only, or target margin on total cost
|
||||
if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost)
|
||||
@@ -675,22 +678,24 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var effectiveBatches = Math.Max(1, ovenBatches);
|
||||
var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate;
|
||||
|
||||
// Scale oven cost by the fraction of total surface area coming from non-AI items.
|
||||
// Use item count as a fallback when surface areas are all zero.
|
||||
var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
var nonAiSqFt = totalSqFt - aiSqFt;
|
||||
// Only items with coating layers go in the oven — sandblast/prep-only items (zero coats) don't.
|
||||
// Of those coating items, AI items already have oven cost baked into their AI price.
|
||||
var coatingItems = items.Where(i => i.Coats != null && i.Coats.Any()).ToList();
|
||||
var nonAiCoatItems = coatingItems.Where(i => !i.IsAiItem).ToList();
|
||||
|
||||
decimal nonAiFraction;
|
||||
if (totalSqFt > 0)
|
||||
if (!coatingItems.Any())
|
||||
{
|
||||
nonAiFraction = nonAiSqFt / totalSqFt;
|
||||
nonAiFraction = 0m; // No coated items — no oven charge
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalCount = items.Count;
|
||||
var aiCount = items.Count(i => i.IsAiItem);
|
||||
nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 1m;
|
||||
var totalCoatSqFt = coatingItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
var nonAiCoatSqFt = nonAiCoatItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||
if (totalCoatSqFt > 0)
|
||||
nonAiFraction = nonAiCoatSqFt / totalCoatSqFt;
|
||||
else
|
||||
nonAiFraction = coatingItems.Count > 0 ? (decimal)nonAiCoatItems.Count / coatingItems.Count : 1m;
|
||||
}
|
||||
|
||||
var ovenBatchCost = fullOvenBatchCost * nonAiFraction;
|
||||
|
||||
@@ -20,6 +20,7 @@ public class InventoryItem : BaseEntity
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? ManufacturerPartNumber { get; set; }
|
||||
public decimal? CoverageSqFtPerLb { get; set; } // Square feet coverage per pound (default 30)
|
||||
public decimal? SpecificGravity { get; set; } // Powder specific gravity from the technical data sheet
|
||||
public decimal? TransferEfficiency { get; set; } // Percentage of powder that sticks (default 65%)
|
||||
public decimal? CureTemperatureF { get; set; } // Required cure temperature in °F (recommended for oven scheduling)
|
||||
public int? CureTimeMinutes { get; set; } // Required hold time at cure temperature
|
||||
|
||||
@@ -52,6 +52,9 @@ public class PowderCatalogItem
|
||||
/// <summary>Theoretical coverage in sq ft per pound. Typical 80–120.</summary>
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
|
||||
/// <summary>Specific gravity from the TDS. Used to derive theoretical coverage when needed.</summary>
|
||||
public decimal? SpecificGravity { get; set; }
|
||||
|
||||
/// <summary>Powder transfer efficiency percentage. Typical 60–75%.</summary>
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ public class QuoteItem : BaseEntity
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
public int EstimatedMinutes { get; set; }
|
||||
|
||||
// Whether to add prep service labor cost on top of this item's base price.
|
||||
// Defaults to false for catalog items (catalog price assumed to include standard labor);
|
||||
// true for calculated items where prep is a separate billable add-on.
|
||||
public bool IncludePrepCost { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
+9525
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSpecificGravityToPowderCatalogAndInventory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SpecificGravity",
|
||||
table: "PowderCatalogItems",
|
||||
type: "decimal(18,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "SpecificGravity",
|
||||
table: "InventoryItems",
|
||||
type: "decimal(18,2)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5288));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5294));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5296));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpecificGravity",
|
||||
table: "PowderCatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpecificGravity",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9528
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQuoteItemIncludePrepCost : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IncludePrepCost",
|
||||
table: "QuoteItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// Non-catalog items were historically submitted with IncludePrepCost=true by the wizard
|
||||
// (wizard defaults: ON for calculated items, OFF for catalog items).
|
||||
// Set existing non-catalog rows to true to preserve their original pricing intent.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE QuoteItems SET IncludePrepCost = 1 WHERE CatalogItemId IS NULL");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IncludePrepCost",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5288));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5294));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 12, 35, 37, 694, DateTimeKind.Utc).AddTicks(5296));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3332,6 +3332,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("SpecPageUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("SpecificGravity")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("TdsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -5835,6 +5838,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal?>("SpecificGravity")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("TdsUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6053,7 +6059,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199),
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6064,7 +6070,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206),
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6075,7 +6081,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208),
|
||||
CreatedAt = new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6588,6 +6594,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("EstimatedMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IncludePrepCost")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsAiItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
|
||||
@@ -257,9 +257,14 @@ When estimating from a verbal description:
|
||||
subtotal = costs.ShopMinimumCharge;
|
||||
|
||||
var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
|
||||
var total = unitPrice * request.Quantity;
|
||||
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
|
||||
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
|
||||
|
||||
// Oven batch charge: 1 batch, DefaultOvenCycleMinutes (fallback 50 min).
|
||||
// Added at quote level (not baked into unitPrice) to match how the regular pricing engine works.
|
||||
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 50;
|
||||
var ovenBatchCost = Math.Round((ovenCycleMinutes / 60m) * costs.OvenOperatingCostPerHour, 2);
|
||||
|
||||
var total = unitPrice * request.Quantity + ovenBatchCost;
|
||||
|
||||
var breakdown = new AiPricingBreakdown
|
||||
{
|
||||
@@ -273,7 +278,7 @@ When estimating from a verbal description:
|
||||
MinFloorApplied = false,
|
||||
LaborCost = Math.Round(laborCost, 2),
|
||||
OvenCycleMinutes = ovenCycleMinutes,
|
||||
OvenCost = 0m,
|
||||
OvenCost = ovenBatchCost,
|
||||
RequiresPreheat = ai.RequiresPreheat,
|
||||
PreheatMinutes = preheatMinutes,
|
||||
PreheatCost = Math.Round(preheatCost, 2),
|
||||
|
||||
@@ -254,8 +254,44 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
||||
Messages = messages
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
|
||||
// On overloaded_error (HTTP 529): retry Sonnet once after a short delay, then
|
||||
// fall back to Haiku (separate capacity pool). If Haiku is also overloaded, give up.
|
||||
// Total worst-case added latency before fallback: ~5s.
|
||||
MessageResponse response;
|
||||
var modelsToTry = new[] { "claude-sonnet-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001" };
|
||||
HttpRequestException? lastOverloadEx = null;
|
||||
response = null!;
|
||||
for (int attempt = 0; attempt < modelsToTry.Length; attempt++)
|
||||
{
|
||||
messageRequest.Model = modelsToTry[attempt];
|
||||
if (attempt > 0)
|
||||
{
|
||||
var delay = attempt == 1 ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(3);
|
||||
_logger.LogWarning("Claude API overloaded on {Model} (attempt {Attempt}); retrying with {NextModel} in {Delay}s",
|
||||
modelsToTry[attempt - 1], attempt, modelsToTry[attempt], delay.TotalSeconds);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
try
|
||||
{
|
||||
response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
|
||||
lastOverloadEx = null;
|
||||
break;
|
||||
}
|
||||
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
|
||||
{
|
||||
lastOverloadEx = hex;
|
||||
}
|
||||
}
|
||||
if (lastOverloadEx != null)
|
||||
{
|
||||
_logger.LogWarning(lastOverloadEx, "Claude API overloaded on all models including fallback");
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
|
||||
};
|
||||
}
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
@@ -329,6 +365,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
||||
ErrorMessage = "The AI service did not respond in time. Please try again."
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error"))
|
||||
{
|
||||
_logger.LogWarning(hex, "Claude API overloaded (outer catch — unexpected path)");
|
||||
return new AiAnalyzeItemResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calling Claude AI for quote analysis");
|
||||
|
||||
@@ -426,6 +426,98 @@ public class FinancialReportService : IFinancialReportService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var invoices = await _context.Invoices
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.SalesTaxAccount)
|
||||
.Where(i => i.CompanyId == companyId
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.AsNoTracking()
|
||||
.OrderBy(i => i.InvoiceDate)
|
||||
.ToListAsync();
|
||||
|
||||
var taxable = invoices.Where(i => i.TaxAmount > 0).ToList();
|
||||
var nonTaxable = invoices.Where(i => i.TaxAmount == 0).ToList();
|
||||
|
||||
var byAccount = invoices
|
||||
.Where(i => i.TaxAmount > 0)
|
||||
.GroupBy(i => new
|
||||
{
|
||||
i.SalesTaxAccountId,
|
||||
AccountName = i.SalesTaxAccount?.Name ?? "Unassigned",
|
||||
AccountNumber = i.SalesTaxAccount?.AccountNumber ?? string.Empty
|
||||
})
|
||||
.Select(g => new SalesTaxByAccountDto
|
||||
{
|
||||
AccountId = g.Key.SalesTaxAccountId,
|
||||
AccountName = g.Key.AccountName,
|
||||
AccountNumber = g.Key.AccountNumber,
|
||||
TaxableSales = g.Sum(i => i.SubTotal),
|
||||
TaxBilled = g.Sum(i => i.TaxAmount),
|
||||
InvoiceCount = g.Count()
|
||||
})
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ThenBy(a => a.AccountName)
|
||||
.ToList();
|
||||
|
||||
var byMonth = invoices
|
||||
.Where(i => i.TaxAmount > 0)
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new SalesTaxByMonthDto
|
||||
{
|
||||
Year = g.Key.Year,
|
||||
Month = g.Key.Month,
|
||||
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
|
||||
TaxableSales = g.Sum(i => i.SubTotal),
|
||||
TaxBilled = g.Sum(i => i.TaxAmount),
|
||||
InvoiceCount = g.Count()
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
var invoiceLines = invoices.Select(i => new SalesTaxInvoiceLineDto
|
||||
{
|
||||
InvoiceId = i.Id,
|
||||
InvoiceNumber = i.InvoiceNumber,
|
||||
CustomerName = i.Customer!.IsCommercial
|
||||
? i.Customer.CompanyName ?? string.Empty
|
||||
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
|
||||
InvoiceDate = i.InvoiceDate,
|
||||
Status = i.Status.ToString(),
|
||||
SubTotal = i.SubTotal,
|
||||
TaxPercent = i.TaxPercent,
|
||||
TaxAmount = i.TaxAmount,
|
||||
Total = i.Total,
|
||||
AmountPaid = i.AmountPaid,
|
||||
BalanceDue = i.BalanceDue,
|
||||
TaxAccountName = i.SalesTaxAccount != null
|
||||
? $"{i.SalesTaxAccount.AccountNumber} {i.SalesTaxAccount.Name}".Trim()
|
||||
: string.Empty
|
||||
}).ToList();
|
||||
|
||||
return new SalesTaxReportDto
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
CompanyName = companyName,
|
||||
TotalTaxableSales = taxable.Sum(i => i.SubTotal),
|
||||
TotalNonTaxableSales = nonTaxable.Sum(i => i.SubTotal),
|
||||
TotalTaxBilled = taxable.Sum(i => i.TaxAmount),
|
||||
TaxableInvoiceCount = taxable.Count,
|
||||
NonTaxableInvoiceCount = nonTaxable.Count,
|
||||
ByAccount = byAccount,
|
||||
ByMonth = byMonth,
|
||||
Invoices = invoiceLines
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||
/// Falls back to "Your Company" if the record is not found.
|
||||
|
||||
@@ -27,6 +27,10 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
/// </summary>
|
||||
public class InventoryAiLookupService : IInventoryAiLookupService
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
private const decimal TheoreticalCoverageConstant = 192.3m;
|
||||
private const decimal DefaultCoverageThicknessMils = 1.5m;
|
||||
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<InventoryAiLookupService> _logger;
|
||||
@@ -47,6 +51,7 @@ Respond ONLY with a valid JSON object — no markdown, no explanation:
|
||||
""cureTimeMinutes"": number or null,
|
||||
""colorFamilies"": ""comma-separated list from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — or null if unknown"",
|
||||
""requiresClearCoat"": true or false or null,
|
||||
""specificGravity"": number or null,
|
||||
""coverageSqFtPerLb"": number or null,
|
||||
""transferEfficiency"": number or null,
|
||||
""unitCostPerLb"": number or null,
|
||||
@@ -250,6 +255,7 @@ Rules:
|
||||
result.CureTimeMinutes = GetInt(parsed, "cureTimeMinutes");
|
||||
result.ColorFamilies = GetString(parsed, "colorFamilies");
|
||||
result.RequiresClearCoat = GetBool(parsed, "requiresClearCoat");
|
||||
result.SpecificGravity = GetDecimal(parsed, "specificGravity");
|
||||
result.CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb");
|
||||
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
|
||||
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
|
||||
@@ -260,6 +266,7 @@ Rules:
|
||||
result.ImageUrl = pageImageUrl;
|
||||
result.Reasoning = GetString(parsed, "reasoning");
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -366,7 +373,7 @@ Rules:
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
var result = new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Manufacturer = GetString(parsed, "manufacturer"),
|
||||
@@ -378,11 +385,15 @@ Rules:
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||||
VendorName = GetString(parsed, "vendorName"),
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -447,7 +458,7 @@ Rules:
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
var result = new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Manufacturer = GetString(parsed, "manufacturer"),
|
||||
@@ -460,6 +471,7 @@ Rules:
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||||
UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb"),
|
||||
@@ -470,6 +482,9 @@ Rules:
|
||||
ImageUrl = pageImageUrl,
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
|
||||
ApplyPowderFallbacks(result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1226,4 +1241,15 @@ Rules:
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ApplyPowderFallbacks(InventoryAiLookupResult result)
|
||||
{
|
||||
result.TransferEfficiency ??= DefaultTransferEfficiency;
|
||||
|
||||
if (!result.CoverageSqFtPerLb.HasValue && result.SpecificGravity is > 0)
|
||||
{
|
||||
var calculatedCoverage = TheoreticalCoverageConstant / (result.SpecificGravity.Value * DefaultCoverageThicknessMils);
|
||||
result.CoverageSqFtPerLb = Math.Round(calculatedCoverage, 2, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ public class AiQuickQuoteController : Controller
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var itemsSubtotal = request.EstimatedUnitPrice * request.Quantity;
|
||||
var ovenCycleMinutes = request.OvenCycleMinutes > 0 ? request.OvenCycleMinutes : 50;
|
||||
|
||||
var quote = new Quote
|
||||
{
|
||||
CompanyId = companyId,
|
||||
@@ -121,12 +124,14 @@ public class AiQuickQuoteController : Controller
|
||||
CustomerPO = request.Reference,
|
||||
MaterialCosts = request.MaterialCost,
|
||||
LaborCosts = request.LaborCost,
|
||||
ItemsSubtotal = request.EstimatedUnitPrice * request.Quantity,
|
||||
SubTotal = request.EstimatedUnitPrice * request.Quantity,
|
||||
Total = request.EstimatedUnitPrice * request.Quantity,
|
||||
ItemsSubtotal = itemsSubtotal,
|
||||
OvenBatches = 1,
|
||||
OvenCycleMinutes = ovenCycleMinutes,
|
||||
OvenBatchCost = request.OvenBatchCost,
|
||||
SubTotal = itemsSubtotal,
|
||||
Total = itemsSubtotal + request.OvenBatchCost,
|
||||
TaxPercent = 0,
|
||||
TaxAmount = 0,
|
||||
OvenBatches = 1
|
||||
TaxAmount = 0
|
||||
};
|
||||
|
||||
if (draftStatus != null)
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace PowderCoating.Web.Controllers
|
||||
private readonly ICatalogImageService _catalogImageService;
|
||||
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public CatalogItemsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -52,7 +53,8 @@ namespace PowderCoating.Web.Controllers
|
||||
ISubscriptionService subscriptionService,
|
||||
ICatalogImageService catalogImageService,
|
||||
IAiCatalogPriceCheckService priceCheckService,
|
||||
IPlatformSettingsService platformSettings)
|
||||
IPlatformSettingsService platformSettings,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -65,6 +67,7 @@ namespace PowderCoating.Web.Controllers
|
||||
_catalogImageService = catalogImageService;
|
||||
_priceCheckService = priceCheckService;
|
||||
_platformSettings = platformSettings;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -906,11 +909,12 @@ namespace PowderCoating.Web.Controllers
|
||||
.ToList();
|
||||
|
||||
// Generate PDF
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
|
||||
itemsByCategory,
|
||||
company.CompanyName,
|
||||
company.LogoData,
|
||||
company.LogoContentType
|
||||
logoData,
|
||||
logoContentType
|
||||
);
|
||||
|
||||
// Return PDF file
|
||||
@@ -1146,6 +1150,17 @@ namespace PowderCoating.Web.Controllers
|
||||
}
|
||||
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for hierarchical display
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
@@ -20,15 +21,18 @@ public class DepositsController : Controller
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<DepositsController> _logger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public DepositsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<DepositsController> logger)
|
||||
ILogger<DepositsController> logger,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -191,7 +195,8 @@ public class DepositsController : Controller
|
||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var pdfBytes = GenerateReceiptPdf(deposit, company?.LogoData, company?.LogoContentType, companyInfo, prefs?.InAccentColor);
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = GenerateReceiptPdf(deposit, logoData, logoContentType, companyInfo, prefs?.InAccentColor);
|
||||
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
|
||||
return File(pdfBytes, "application/pdf");
|
||||
}
|
||||
@@ -413,4 +418,15 @@ public class DepositsController : Controller
|
||||
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
||||
return hex.StartsWith("#") ? hex : fallback;
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,19 +30,22 @@ public class GiftCertificatesController : Controller
|
||||
private readonly ILogger<GiftCertificatesController> _logger;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public GiftCertificatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ILogger<GiftCertificatesController> logger,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IPdfService pdfService)
|
||||
IPdfService pdfService,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_logger = logger;
|
||||
_userManager = userManager;
|
||||
_pdfService = pdfService;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -340,7 +343,8 @@ public class GiftCertificatesController : Controller
|
||||
|
||||
try
|
||||
{
|
||||
var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo);
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateGiftCertificatePdfAsync(dto, logoData, logoContentType, companyInfo);
|
||||
return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -390,4 +394,15 @@ public class GiftCertificatesController : Controller
|
||||
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
|
||||
ViewBag.Customers = list;
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
public class InventoryController : Controller
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<InventoryController> _logger;
|
||||
@@ -745,7 +747,8 @@ public class InventoryController : Controller
|
||||
if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies;
|
||||
if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat;
|
||||
if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb;
|
||||
if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency;
|
||||
if (match.SpecificGravity != null) result.SpecificGravity = match.SpecificGravity;
|
||||
result.TransferEfficiency ??= GetEffectiveTransferEfficiency(match.TransferEfficiency);
|
||||
// URL / price fields: fill gaps only — AI may have found something better
|
||||
result.ImageUrl ??= match.ImageUrl;
|
||||
result.SpecPageUrl ??= match.ProductUrl;
|
||||
@@ -775,7 +778,8 @@ public class InventoryController : Controller
|
||||
ColorFamilies = result.ColorFamilies,
|
||||
RequiresClearCoat = result.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
|
||||
TransferEfficiency = result.TransferEfficiency,
|
||||
SpecificGravity = result.SpecificGravity,
|
||||
TransferEfficiency = GetEffectiveTransferEfficiency(result.TransferEfficiency),
|
||||
ImageUrl = result.ImageUrl,
|
||||
ProductUrl = result.SpecPageUrl,
|
||||
SdsUrl = result.SdsUrl,
|
||||
@@ -873,7 +877,8 @@ public class InventoryController : Controller
|
||||
aiResult.CureTimeMinutes ??= full.CureTimeMinutes;
|
||||
aiResult.RequiresClearCoat ??= full.RequiresClearCoat;
|
||||
aiResult.CoverageSqFtPerLb ??= full.CoverageSqFtPerLb;
|
||||
aiResult.TransferEfficiency ??= full.TransferEfficiency;
|
||||
aiResult.SpecificGravity ??= full.SpecificGravity;
|
||||
aiResult.TransferEfficiency ??= GetEffectiveTransferEfficiency(full.TransferEfficiency);
|
||||
aiResult.ManufacturerPartNumber ??= full.ManufacturerPartNumber;
|
||||
aiResult.ColorName ??= full.ColorName;
|
||||
aiResult.ColorCode ??= full.ColorCode;
|
||||
@@ -952,7 +957,8 @@ public class InventoryController : Controller
|
||||
colorFamilies = aiResult.ColorFamilies,
|
||||
requiresClearCoat = aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = aiResult.TransferEfficiency,
|
||||
specificGravity = aiResult.SpecificGravity,
|
||||
transferEfficiency = aiResult.TransferEfficiency ?? DefaultTransferEfficiency,
|
||||
unitPrice = aiResult.UnitCostPerLb ?? 0m,
|
||||
imageUrl = aiResult.ImageUrl,
|
||||
productUrl = aiResult.SpecPageUrl,
|
||||
@@ -1104,13 +1110,19 @@ public class InventoryController : Controller
|
||||
colorFamilies = p.ColorFamilies,
|
||||
requiresClearCoat = p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||||
transferEfficiency = p.TransferEfficiency
|
||||
specificGravity = p.SpecificGravity,
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
||||
/// inventory item names on create and edit so the list view is consistently formatted
|
||||
|
||||
@@ -27,6 +27,7 @@ public class InvoicesController : Controller
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public InvoicesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -36,7 +37,8 @@ public class InvoicesController : Controller
|
||||
IPdfService pdfService,
|
||||
ITenantContext tenantContext,
|
||||
INotificationService notificationService,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
IAccountBalanceService accountBalanceService,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -46,6 +48,7 @@ public class InvoicesController : Controller
|
||||
_tenantContext = tenantContext;
|
||||
_notificationService = notificationService;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1624,8 +1627,9 @@ public class InvoicesController : Controller
|
||||
DefaultTerms = prefs?.InDefaultTerms
|
||||
};
|
||||
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var dto = await BuildInvoiceDtoAsync(invoice);
|
||||
return await _pdfService.GenerateInvoicePdfAsync(dto, company?.LogoData, company?.LogoContentType, companyInfo, template);
|
||||
return await _pdfService.GenerateInvoicePdfAsync(dto, logoData, logoContentType, companyInfo, template);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2444,4 +2448,19 @@ public class InvoicesController : Controller
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns logo bytes and content type for PDF generation.
|
||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||
/// </summary>
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -12,34 +15,326 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class PowderCatalogController : Controller
|
||||
{
|
||||
private const decimal DefaultTransferEfficiency = 65m;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<PowderCatalogController> _logger;
|
||||
|
||||
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
|
||||
public PowderCatalogController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<PowderCatalogController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows platform-level catalog stats and the import form.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
public async Task<IActionResult> Index(
|
||||
string? searchTerm,
|
||||
string? vendorName,
|
||||
string status = "all",
|
||||
string source = "all",
|
||||
string completeness = "all",
|
||||
string? sortColumn = null,
|
||||
string sortDirection = "asc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
{
|
||||
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
|
||||
var list = all.ToList();
|
||||
|
||||
var stats = new PowderCatalogStatsDto
|
||||
var stats = BuildStats(list);
|
||||
var vendors = list
|
||||
.Select(p => p.VendorName)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v)
|
||||
.ToList();
|
||||
|
||||
status = NormalizeFilter(status, "all", "active", "discontinued");
|
||||
source = NormalizeFilter(source, "all", "curated", "contributed");
|
||||
completeness = NormalizeFilter(completeness, "all", "ready", "missing-specs", "missing-docs", "missing-image");
|
||||
sortDirection = sortDirection?.Equals("desc", StringComparison.OrdinalIgnoreCase) == true ? "desc" : "asc";
|
||||
sortColumn = NormalizeSortColumn(sortColumn);
|
||||
pageNumber = Math.Max(pageNumber, 1);
|
||||
pageSize = pageSize is < 1 or > 100 ? 25 : pageSize;
|
||||
|
||||
IEnumerable<PowderCatalogItem> query = list;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
TotalProducts = list.Count,
|
||||
ActiveProducts = list.Count(p => !p.IsDiscontinued),
|
||||
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
|
||||
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
UserContributedProducts = list.Count(p => p.IsUserContributed),
|
||||
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
|
||||
var term = searchTerm.Trim();
|
||||
query = query.Where(p =>
|
||||
ContainsIgnoreCase(p.VendorName, term) ||
|
||||
ContainsIgnoreCase(p.Sku, term) ||
|
||||
ContainsIgnoreCase(p.ColorName, term) ||
|
||||
ContainsIgnoreCase(p.Description, term) ||
|
||||
ContainsIgnoreCase(p.Finish, term));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vendorName))
|
||||
query = query.Where(p => string.Equals(p.VendorName, vendorName.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
query = status switch
|
||||
{
|
||||
"active" => query.Where(p => !p.IsDiscontinued),
|
||||
"discontinued" => query.Where(p => p.IsDiscontinued),
|
||||
_ => query
|
||||
};
|
||||
|
||||
return View(stats);
|
||||
query = source switch
|
||||
{
|
||||
"curated" => query.Where(p => !p.IsUserContributed),
|
||||
"contributed" => query.Where(p => p.IsUserContributed),
|
||||
_ => query
|
||||
};
|
||||
|
||||
query = completeness switch
|
||||
{
|
||||
"ready" => query.Where(IsCatalogReady),
|
||||
"missing-specs" => query.Where(p => !HasCoreSpecs(p)),
|
||||
"missing-docs" => query.Where(p => !HasDocuments(p)),
|
||||
"missing-image" => query.Where(p => string.IsNullOrWhiteSpace(p.ImageUrl)),
|
||||
_ => query
|
||||
};
|
||||
|
||||
query = ApplySort(query, sortColumn, sortDirection);
|
||||
|
||||
var totalCount = query.Count();
|
||||
var items = query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(MapListItem)
|
||||
.ToList();
|
||||
|
||||
var vm = new PowderCatalogIndexViewModel
|
||||
{
|
||||
Stats = stats,
|
||||
Vendors = vendors,
|
||||
SearchTerm = searchTerm,
|
||||
VendorName = vendorName,
|
||||
Status = status,
|
||||
Source = source,
|
||||
Completeness = completeness,
|
||||
SortColumn = sortColumn,
|
||||
SortDirection = sortDirection,
|
||||
Catalog = new PagedResult<PowderCatalogListItemViewModel>
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = sortColumn,
|
||||
SortDirection = sortDirection,
|
||||
SearchTerm = searchTerm
|
||||
}
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View(new PowderCatalogFormViewModel
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
TransferEfficiency = DefaultTransferEfficiency
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(PowderCatalogFormViewModel model)
|
||||
{
|
||||
NormalizeModel(model);
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var entity = new PowderCatalogItem
|
||||
{
|
||||
VendorName = model.VendorName,
|
||||
Sku = model.Sku,
|
||||
ColorName = model.ColorName,
|
||||
Description = NullIfWhiteSpace(model.Description),
|
||||
UnitPrice = model.UnitPrice,
|
||||
ImageUrl = NullIfWhiteSpace(model.ImageUrl),
|
||||
SdsUrl = NullIfWhiteSpace(model.SdsUrl),
|
||||
TdsUrl = NullIfWhiteSpace(model.TdsUrl),
|
||||
ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl),
|
||||
ProductUrl = NullIfWhiteSpace(model.ProductUrl),
|
||||
CureTemperatureF = model.CureTemperatureF,
|
||||
CureTimeMinutes = model.CureTimeMinutes,
|
||||
Finish = NullIfWhiteSpace(model.Finish),
|
||||
ColorFamilies = NullIfWhiteSpace(model.ColorFamilies),
|
||||
RequiresClearCoat = model.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = model.CoverageSqFtPerLb,
|
||||
SpecificGravity = model.SpecificGravity,
|
||||
TransferEfficiency = model.TransferEfficiency,
|
||||
IsDiscontinued = model.IsDiscontinued,
|
||||
IsUserContributed = model.IsUserContributed,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Added powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating powder catalog item {VendorName} {Sku}", model.VendorName, model.Sku);
|
||||
TempData["Error"] = "An error occurred while creating the powder catalog item.";
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
return View(MapForm(entity));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, PowderCatalogFormViewModel model)
|
||||
{
|
||||
if (id != model.Id)
|
||||
return NotFound();
|
||||
|
||||
NormalizeModel(model);
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return View(model);
|
||||
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
if (await CatalogRecordExistsAsync(model.VendorName, model.Sku, id))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "A powder catalog item with this vendor and SKU already exists.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
entity.VendorName = model.VendorName;
|
||||
entity.Sku = model.Sku;
|
||||
entity.ColorName = model.ColorName;
|
||||
entity.Description = NullIfWhiteSpace(model.Description);
|
||||
entity.UnitPrice = model.UnitPrice;
|
||||
entity.ImageUrl = NullIfWhiteSpace(model.ImageUrl);
|
||||
entity.SdsUrl = NullIfWhiteSpace(model.SdsUrl);
|
||||
entity.TdsUrl = NullIfWhiteSpace(model.TdsUrl);
|
||||
entity.ApplicationGuideUrl = NullIfWhiteSpace(model.ApplicationGuideUrl);
|
||||
entity.ProductUrl = NullIfWhiteSpace(model.ProductUrl);
|
||||
entity.CureTemperatureF = model.CureTemperatureF;
|
||||
entity.CureTimeMinutes = model.CureTimeMinutes;
|
||||
entity.Finish = NullIfWhiteSpace(model.Finish);
|
||||
entity.ColorFamilies = NullIfWhiteSpace(model.ColorFamilies);
|
||||
entity.RequiresClearCoat = model.RequiresClearCoat;
|
||||
entity.CoverageSqFtPerLb = model.CoverageSqFtPerLb;
|
||||
entity.SpecificGravity = model.SpecificGravity;
|
||||
entity.TransferEfficiency = model.TransferEfficiency;
|
||||
entity.IsDiscontinued = model.IsDiscontinued;
|
||||
entity.IsUserContributed = model.IsUserContributed;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Updated powder catalog item \"{entity.ColorName}\" ({entity.Sku}).";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating powder catalog item {Id}", id);
|
||||
TempData["Error"] = "An error occurred while updating the powder catalog item.";
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleDiscontinued(int id)
|
||||
{
|
||||
var entity = await _unitOfWork.PowderCatalog.GetByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound();
|
||||
|
||||
entity.IsDiscontinued = !entity.IsDiscontinued;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = entity.IsDiscontinued
|
||||
? $"Marked \"{entity.ColorName}\" as discontinued."
|
||||
: $"Reactivated \"{entity.ColorName}\".";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error toggling discontinued status for powder catalog item {Id}", id);
|
||||
TempData["Error"] = "An error occurred while updating the catalog item status.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiLookup(
|
||||
[FromForm] string? vendorName,
|
||||
[FromForm] string? colorName,
|
||||
[FromForm] string? sku)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendorName)
|
||||
&& string.IsNullOrWhiteSpace(colorName)
|
||||
&& string.IsNullOrWhiteSpace(sku))
|
||||
{
|
||||
return Json(new { success = false, errorMessage = "Enter a vendor, color name, or SKU first." });
|
||||
}
|
||||
|
||||
var result = await _aiLookupService.LookupAsync(vendorName, colorName, null, sku);
|
||||
if (result.Success)
|
||||
await ApplyTdsCureFallbackAsync(result, colorName);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiAugmentFromUrl(
|
||||
[FromForm] string? productUrl,
|
||||
[FromForm] string? colorName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(productUrl))
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
if (result.Success)
|
||||
await ApplyTdsCureFallbackAsync(result, colorName);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,7 +370,7 @@ public class PowderCatalogController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
|
||||
TempData["Success"] = $"Import complete - {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -119,6 +414,7 @@ public class PowderCatalogController : Controller
|
||||
TdsUrl = p.TdsUrl,
|
||||
ApplicationGuideUrl = p.ApplicationGuideUrl,
|
||||
ProductUrl = p.ProductUrl,
|
||||
SpecificGravity = p.SpecificGravity,
|
||||
IsDiscontinued = p.IsDiscontinued
|
||||
})
|
||||
.ToList();
|
||||
@@ -126,7 +422,21 @@ public class PowderCatalogController : Controller
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
|
||||
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
||||
{
|
||||
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
|
||||
&& !string.IsNullOrWhiteSpace(result.TdsUrl))
|
||||
{
|
||||
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
|
||||
if (tds.Success)
|
||||
{
|
||||
result.CureTemperatureF ??= tds.CureTemperatureF;
|
||||
result.CureTimeMinutes ??= tds.CureTimeMinutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
|
||||
{
|
||||
@@ -275,6 +585,185 @@ public class PowderCatalogController : Controller
|
||||
|
||||
return price ?? 0m;
|
||||
}
|
||||
|
||||
private static PowderCatalogStatsDto BuildStats(List<PowderCatalogItem> list)
|
||||
{
|
||||
return new PowderCatalogStatsDto
|
||||
{
|
||||
TotalProducts = list.Count,
|
||||
ActiveProducts = list.Count(p => !p.IsDiscontinued),
|
||||
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
|
||||
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
UserContributedProducts = list.Count(p => p.IsUserContributed),
|
||||
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<PowderCatalogItem> ApplySort(IEnumerable<PowderCatalogItem> query, string sortColumn, string sortDirection)
|
||||
{
|
||||
var descending = sortDirection.Equals("desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return (sortColumn, descending) switch
|
||||
{
|
||||
("Sku", false) => query.OrderBy(p => p.Sku).ThenBy(p => p.ColorName),
|
||||
("Sku", true) => query.OrderByDescending(p => p.Sku).ThenBy(p => p.ColorName),
|
||||
("ColorName", false) => query.OrderBy(p => p.ColorName).ThenBy(p => p.Sku),
|
||||
("ColorName", true) => query.OrderByDescending(p => p.ColorName).ThenBy(p => p.Sku),
|
||||
("UnitPrice", false) => query.OrderBy(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
||||
("UnitPrice", true) => query.OrderByDescending(p => p.UnitPrice).ThenBy(p => p.ColorName),
|
||||
("Finish", false) => query.OrderBy(p => p.Finish).ThenBy(p => p.ColorName),
|
||||
("Finish", true) => query.OrderByDescending(p => p.Finish).ThenBy(p => p.ColorName),
|
||||
("UpdatedAt", false) => query.OrderBy(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
||||
("UpdatedAt", true) => query.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt).ThenBy(p => p.ColorName),
|
||||
("LastSyncedAt", false) => query.OrderBy(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
||||
("LastSyncedAt", true) => query.OrderByDescending(p => p.LastSyncedAt ?? DateTime.MinValue).ThenBy(p => p.ColorName),
|
||||
("VendorName", true) => query.OrderByDescending(p => p.VendorName).ThenBy(p => p.ColorName),
|
||||
_ => query.OrderBy(p => p.VendorName).ThenBy(p => p.ColorName)
|
||||
};
|
||||
}
|
||||
|
||||
private static PowderCatalogListItemViewModel MapListItem(PowderCatalogItem item)
|
||||
{
|
||||
return new PowderCatalogListItemViewModel
|
||||
{
|
||||
Id = item.Id,
|
||||
VendorName = item.VendorName,
|
||||
Sku = item.Sku,
|
||||
ColorName = item.ColorName,
|
||||
Finish = item.Finish,
|
||||
UnitPrice = item.UnitPrice,
|
||||
IsDiscontinued = item.IsDiscontinued,
|
||||
IsUserContributed = item.IsUserContributed,
|
||||
HasImage = !string.IsNullOrWhiteSpace(item.ImageUrl),
|
||||
HasCoreSpecs = HasCoreSpecs(item),
|
||||
HasDocuments = HasDocuments(item),
|
||||
CreatedAt = item.CreatedAt,
|
||||
UpdatedAt = item.UpdatedAt,
|
||||
LastSyncedAt = item.LastSyncedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static PowderCatalogFormViewModel MapForm(PowderCatalogItem item)
|
||||
{
|
||||
return new PowderCatalogFormViewModel
|
||||
{
|
||||
Id = item.Id,
|
||||
VendorName = item.VendorName,
|
||||
Sku = item.Sku,
|
||||
ColorName = item.ColorName,
|
||||
Description = item.Description,
|
||||
UnitPrice = item.UnitPrice,
|
||||
ImageUrl = item.ImageUrl,
|
||||
SdsUrl = item.SdsUrl,
|
||||
TdsUrl = item.TdsUrl,
|
||||
ApplicationGuideUrl = item.ApplicationGuideUrl,
|
||||
ProductUrl = item.ProductUrl,
|
||||
CureTemperatureF = item.CureTemperatureF,
|
||||
CureTimeMinutes = item.CureTimeMinutes,
|
||||
Finish = item.Finish,
|
||||
ColorFamilies = item.ColorFamilies,
|
||||
RequiresClearCoat = item.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = item.CoverageSqFtPerLb,
|
||||
SpecificGravity = item.SpecificGravity,
|
||||
TransferEfficiency = GetEffectiveTransferEfficiency(item.TransferEfficiency),
|
||||
IsDiscontinued = item.IsDiscontinued,
|
||||
IsUserContributed = item.IsUserContributed,
|
||||
CreatedAt = item.CreatedAt,
|
||||
UpdatedAt = item.UpdatedAt,
|
||||
LastSyncedAt = item.LastSyncedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasCoreSpecs(PowderCatalogItem item)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(item.Finish)
|
||||
&& item.CureTemperatureF.HasValue
|
||||
&& item.CureTimeMinutes.HasValue
|
||||
&& item.CoverageSqFtPerLb.HasValue
|
||||
&& GetEffectiveTransferEfficiency(item.TransferEfficiency).HasValue;
|
||||
}
|
||||
|
||||
private static bool HasDocuments(PowderCatalogItem item)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(item.ProductUrl)
|
||||
&& !string.IsNullOrWhiteSpace(item.SdsUrl)
|
||||
&& !string.IsNullOrWhiteSpace(item.TdsUrl);
|
||||
}
|
||||
|
||||
private static bool IsCatalogReady(PowderCatalogItem item)
|
||||
{
|
||||
return HasCoreSpecs(item)
|
||||
&& HasDocuments(item)
|
||||
&& !string.IsNullOrWhiteSpace(item.ImageUrl);
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? value, string searchTerm)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value)
|
||||
&& value.Contains(searchTerm, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeFilter(string? value, string fallback, params string[] allowedValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return fallback;
|
||||
|
||||
return allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase)
|
||||
? value.ToLowerInvariant()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string NormalizeSortColumn(string? sortColumn)
|
||||
{
|
||||
var allowed = new[]
|
||||
{
|
||||
"VendorName", "Sku", "ColorName", "Finish", "UnitPrice", "UpdatedAt", "LastSyncedAt"
|
||||
};
|
||||
|
||||
return allowed.Contains(sortColumn, StringComparer.OrdinalIgnoreCase)
|
||||
? allowed.First(s => s.Equals(sortColumn, StringComparison.OrdinalIgnoreCase))
|
||||
: "VendorName";
|
||||
}
|
||||
|
||||
private async Task<bool> CatalogRecordExistsAsync(string vendorName, string sku, int? excludeId = null)
|
||||
{
|
||||
var existing = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.VendorName.ToLower() == vendorName.ToLower() &&
|
||||
p.Sku.ToLower() == sku.ToLower());
|
||||
|
||||
return existing.Any(p => excludeId == null || p.Id != excludeId.Value);
|
||||
}
|
||||
|
||||
private static void NormalizeModel(PowderCatalogFormViewModel model)
|
||||
{
|
||||
model.VendorName = model.VendorName?.Trim() ?? string.Empty;
|
||||
model.Sku = model.Sku?.Trim() ?? string.Empty;
|
||||
model.ColorName = model.ColorName?.Trim() ?? string.Empty;
|
||||
model.Description = TrimToNull(model.Description);
|
||||
model.ImageUrl = TrimToNull(model.ImageUrl);
|
||||
model.SdsUrl = TrimToNull(model.SdsUrl);
|
||||
model.TdsUrl = TrimToNull(model.TdsUrl);
|
||||
model.ApplicationGuideUrl = TrimToNull(model.ApplicationGuideUrl);
|
||||
model.ProductUrl = TrimToNull(model.ProductUrl);
|
||||
model.Finish = TrimToNull(model.Finish);
|
||||
model.ColorFamilies = TrimToNull(model.ColorFamilies);
|
||||
model.TransferEfficiency ??= DefaultTransferEfficiency;
|
||||
}
|
||||
|
||||
private static string? TrimToNull(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NullIfWhiteSpace(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static decimal? GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||
{
|
||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
|
||||
|
||||
@@ -23,19 +23,22 @@ public class PurchaseOrdersController : Controller
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<PurchaseOrdersController> _logger;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public PurchaseOrdersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<PurchaseOrdersController> logger,
|
||||
IPdfService pdfService)
|
||||
IPdfService pdfService,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_pdfService = pdfService;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -684,8 +687,9 @@ public class PurchaseOrdersController : Controller
|
||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
|
||||
dto, company?.LogoData, company?.LogoContentType, companyInfo);
|
||||
dto, logoData, logoContentType, companyInfo);
|
||||
|
||||
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
|
||||
}
|
||||
@@ -847,4 +851,15 @@ public class PurchaseOrdersController : Controller
|
||||
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
||||
ViewBag.VendorList = vendors;
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ public class QuotesController : Controller
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly IJobPhotoService _jobPhotoService;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
|
||||
public QuotesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -59,7 +60,8 @@ public class QuotesController : Controller
|
||||
IAiQuoteService aiService,
|
||||
IWebHostEnvironment env,
|
||||
IJobPhotoService jobPhotoService,
|
||||
IAiUsageLogger usageLogger)
|
||||
IAiUsageLogger usageLogger,
|
||||
ICompanyLogoService logoService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -79,6 +81,7 @@ public class QuotesController : Controller
|
||||
_env = env;
|
||||
_jobPhotoService = jobPhotoService;
|
||||
_usageLogger = usageLogger;
|
||||
_logoService = logoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -362,33 +365,32 @@ public class QuotesController : Controller
|
||||
}
|
||||
|
||||
// Build pricing breakdown from stored snapshot values — never recalculate on load
|
||||
if (quote.Total > 0)
|
||||
// OvenCycleMinutes null means "use company default"; resolve it here so the view
|
||||
// never displays "× 0 min" when the oven was priced against DefaultOvenCycleMinutes.
|
||||
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
Total = quote.Total
|
||||
};
|
||||
}
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
Total = quote.Total
|
||||
};
|
||||
|
||||
// Load change history
|
||||
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
|
||||
@@ -539,35 +541,35 @@ public class QuotesController : Controller
|
||||
|
||||
// Get company info and logo
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var pdfOperatingCosts = currentUser != null
|
||||
? await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId)
|
||||
: null;
|
||||
|
||||
// Populate pricing breakdown from stored snapshot values — never recalculate on load
|
||||
if (quote.Total > 0)
|
||||
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
|
||||
{
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
Total = quote.Total
|
||||
};
|
||||
}
|
||||
MaterialCosts = quote.MaterialCosts,
|
||||
LaborCosts = quote.LaborCosts,
|
||||
EquipmentCosts = quote.EquipmentCosts,
|
||||
ItemsSubtotal = quote.ItemsSubtotal,
|
||||
OvenBatchCost = quote.OvenBatchCost,
|
||||
OvenBatches = quote.OvenBatches,
|
||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||
OverheadCosts = quote.OverheadAmount,
|
||||
OverheadPercent = quote.OverheadPercent,
|
||||
ProfitMargin = quote.ProfitMargin,
|
||||
ProfitPercent = quote.ProfitPercent,
|
||||
SubtotalBeforeDiscount = quote.SubTotal,
|
||||
DiscountAmount = quote.DiscountAmount,
|
||||
DiscountPercent = quote.DiscountPercent,
|
||||
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
|
||||
RushFee = quote.RushFee,
|
||||
TaxPercent = quote.TaxPercent,
|
||||
TaxAmount = quote.TaxAmount,
|
||||
Total = quote.Total
|
||||
};
|
||||
if (currentUser?.CompanyId == null)
|
||||
{
|
||||
TempData["Error"] = "Company information not found.";
|
||||
@@ -604,10 +606,11 @@ public class QuotesController : Controller
|
||||
};
|
||||
|
||||
// Generate PDF
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
|
||||
quoteDto,
|
||||
company.LogoData,
|
||||
company.LogoContentType,
|
||||
logoData,
|
||||
logoContentType,
|
||||
companyInfo,
|
||||
template: template
|
||||
);
|
||||
@@ -1037,13 +1040,22 @@ public class QuotesController : Controller
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Promote AI temp photos to permanent storage and create QuotePhoto records
|
||||
_logger.LogInformation("CREATE AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
|
||||
dto.AiPhotoTempIds?.Count ?? 0,
|
||||
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
|
||||
if (dto.AiPhotoTempIds?.Count > 0)
|
||||
{
|
||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
|
||||
if (!Guid.TryParse(rawTempId, out var tempGuid))
|
||||
{
|
||||
_logger.LogWarning("CREATE AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
|
||||
continue;
|
||||
}
|
||||
var tempId = tempGuid.ToString("N");
|
||||
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||
_logger.LogInformation("CREATE AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
|
||||
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||
_logger.LogInformation("CREATE AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
|
||||
if (promoted)
|
||||
{
|
||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||
@@ -1895,13 +1907,22 @@ public class QuotesController : Controller
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Promote any new AI temp photos and create QuotePhoto records
|
||||
_logger.LogInformation("EDIT AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
|
||||
dto.AiPhotoTempIds?.Count ?? 0,
|
||||
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
|
||||
if (dto.AiPhotoTempIds?.Count > 0)
|
||||
{
|
||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
|
||||
if (!Guid.TryParse(rawTempId, out var tempGuid))
|
||||
{
|
||||
_logger.LogWarning("EDIT AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
|
||||
continue;
|
||||
}
|
||||
var tempId = tempGuid.ToString("N");
|
||||
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||
_logger.LogInformation("EDIT AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
|
||||
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
|
||||
_logger.LogInformation("EDIT AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
|
||||
if (promoted)
|
||||
{
|
||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||
@@ -2826,10 +2847,11 @@ public class QuotesController : Controller
|
||||
DefaultTerms = prefs?.QtDefaultTerms
|
||||
};
|
||||
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
return await _pdfService.GenerateQuotePdfAsync(
|
||||
quoteDto,
|
||||
company.LogoData,
|
||||
company.LogoContentType,
|
||||
logoData,
|
||||
logoContentType,
|
||||
companyInfo,
|
||||
template: template);
|
||||
}
|
||||
@@ -3129,6 +3151,13 @@ public class QuotesController : Controller
|
||||
quote.ConvertedDate = DateTime.UtcNow;
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// The interceptor just bumped quote.UpdatedAt as part of the ConvertedToJobId write.
|
||||
// Advance the job's snapshot past that update — otherwise the comparison
|
||||
// job.Quote.UpdatedAt > job.QuoteSnapshotUpdatedAt is immediately true and the
|
||||
// "source quote was modified" banner fires on every newly-converted job.
|
||||
job.QuoteSnapshotUpdatedAt = quote.UpdatedAt ?? quote.CreatedAt;
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Copy all quote photos to job (leave originals on the quote)
|
||||
// AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits
|
||||
try
|
||||
@@ -3908,6 +3937,30 @@ public class QuotesController : Controller
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>Updates the caption of a non-AI quote photo.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateQuotePhoto(int id, string? caption)
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user == null) return Json(new { success = false, error = "Not authenticated." });
|
||||
|
||||
var photo = await _unitOfWork.QuotePhotos.GetByIdAsync(id);
|
||||
if (photo == null || photo.CompanyId != user.CompanyId)
|
||||
return Json(new { success = false, error = "Photo not found." });
|
||||
|
||||
if (photo.IsAiAnalysisPhoto)
|
||||
return Json(new { success = false, error = "AI analysis photos cannot be edited." });
|
||||
|
||||
photo.Caption = string.IsNullOrWhiteSpace(caption) ? null : caption.Trim();
|
||||
photo.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.QuotePhotos.UpdateAsync(photo);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||
{
|
||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
@@ -3936,6 +3989,21 @@ public class QuotesController : Controller
|
||||
|
||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns logo bytes and content type for PDF generation.
|
||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData)
|
||||
/// so that companies that uploaded a new logo after initial setup see it in PDFs.
|
||||
/// </summary>
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company company)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(company.LogoFilePath))
|
||||
{
|
||||
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (ok) return (content, contentType);
|
||||
}
|
||||
return (company.LogoData, company.LogoContentType);
|
||||
}
|
||||
}
|
||||
|
||||
// Request model for AJAX pricing calculation
|
||||
|
||||
@@ -1124,6 +1124,74 @@ public class ReportsController : Controller
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sales Tax Liability report (invoice basis). Shows taxable vs non-taxable sales,
|
||||
/// total tax billed, breakdown by tax account and by month, and a full invoice detail grid.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/SalesTax
|
||||
public async Task<IActionResult> SalesTax(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF export of the Sales Tax Liability report. Same inline/attachment pattern as other PDF actions.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/SalesTaxPdf
|
||||
public async Task<IActionResult> SalesTaxPdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateSalesTaxReportPdfAsync(dto);
|
||||
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV export of the Sales Tax Liability report. Returns one row per invoice, suitable
|
||||
/// for handing to an accountant or importing into tax filing software.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/SalesTaxCsv
|
||||
public async Task<IActionResult> SalesTaxCsv(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetSalesTaxReportAsync(companyId, fromDate, toDate);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("Invoice #,Customer,Date,Status,Subtotal,Tax %,Tax Amount,Total,Amount Paid,Balance Due,Tax Account");
|
||||
foreach (var inv in dto.Invoices)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
$"\"{inv.InvoiceNumber}\"",
|
||||
$"\"{inv.CustomerName.Replace("\"", "\"\"")}\"",
|
||||
inv.InvoiceDate.ToString("yyyy-MM-dd"),
|
||||
$"\"{inv.Status}\"",
|
||||
inv.SubTotal.ToString("F2"),
|
||||
inv.TaxPercent.ToString("F4"),
|
||||
inv.TaxAmount.ToString("F2"),
|
||||
inv.Total.ToString("F2"),
|
||||
inv.AmountPaid.ToString("F2"),
|
||||
inv.BalanceDue.ToString("F2"),
|
||||
$"\"{inv.TaxAccountName.Replace("\"", "\"\"")}\""));
|
||||
}
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetPreamble().Concat(System.Text.Encoding.UTF8.GetBytes(sb.ToString())).ToArray();
|
||||
return File(bytes, "text/csv", $"SalesTaxReport-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.csv");
|
||||
}
|
||||
|
||||
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -569,6 +569,7 @@ public static class HelpKnowledgeBase
|
||||
- *Balance Sheet* — assets, liabilities, equity snapshot
|
||||
- *AR Aging* — outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
|
||||
- *Sales & Income* — revenue trends by period
|
||||
- *Sales Tax Report* — invoice-basis tax liability: taxable vs non-taxable sales, tax billed by account and by month, full invoice detail grid. Supports PDF export and CSV export (for handing to your accountant or tax software). Found under Reports → Finance.
|
||||
- *Revenue Trends* — monthly/quarterly revenue charting
|
||||
- *Operations Report* — job throughput, cycle times, status breakdown
|
||||
- *Customer Overview* — top customers, revenue per customer
|
||||
@@ -579,7 +580,7 @@ public static class HelpKnowledgeBase
|
||||
- *Powder Usage Report* — powder consumption by item/job
|
||||
- *Job Cycle Time Report* — how long jobs spend in each status
|
||||
|
||||
Most financial reports support PDF export.
|
||||
Most financial reports support PDF export. The Sales Tax Report also supports CSV export.
|
||||
|
||||
---
|
||||
|
||||
@@ -1216,7 +1217,7 @@ public static class HelpKnowledgeBase
|
||||
|
||||
The system includes several AI-powered features (all use Claude by Anthropic):
|
||||
|
||||
1. **AI Photo Quote** — Upload photos of items on a quote; AI estimates surface area, complexity, and labor time. Available in the quote item wizard. *Availability depends on your subscription plan.* The AI is tuned for consistency — running the same photo through the wizard multiple times should produce very similar estimates each time (small differences may occur due to the visual nature of photo analysis, but the numbers will be in the same ballpark rather than wildly different).
|
||||
1. **AI Photo Quote** — Upload photos of items on a quote; AI estimates surface area, complexity, and labor time. Available in the quote item wizard. *Availability depends on your subscription plan.* The AI is tuned for consistency — running the same photo through the wizard multiple times should produce very similar estimates each time (small differences may occur due to the visual nature of photo analysis, but the numbers will be in the same ballpark rather than wildly different). If the AI service is temporarily under high demand, the system will automatically retry (including a fallback to a secondary model) before showing an error — so a single click will usually succeed even if Anthropic's servers are briefly busy.
|
||||
|
||||
2. **AI Inventory Assist** — AI-powered product lookup when adding or editing inventory items. Click the AI lookup button on the inventory form to auto-fill product details from a part name or description. *Availability depends on your subscription plan.*
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
|
||||
public class PowderCatalogFormViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(120)]
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "SKU")]
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(160)]
|
||||
[Display(Name = "Color Name")]
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(4000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Range(0, 999999)]
|
||||
[Display(Name = "Unit Price")]
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Image URL")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "SDS URL")]
|
||||
public string? SdsUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "TDS URL")]
|
||||
public string? TdsUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Application Guide URL")]
|
||||
public string? ApplicationGuideUrl { get; set; }
|
||||
|
||||
[Url]
|
||||
[Display(Name = "Product URL")]
|
||||
public string? ProductUrl { get; set; }
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Cure Temperature (F)")]
|
||||
public decimal? CureTemperatureF { get; set; }
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Cure Time (Minutes)")]
|
||||
public int? CureTimeMinutes { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? Finish { get; set; }
|
||||
|
||||
[StringLength(300)]
|
||||
[Display(Name = "Color Families")]
|
||||
public string? ColorFamilies { get; set; }
|
||||
|
||||
[Display(Name = "Requires Clear Coat")]
|
||||
public bool? RequiresClearCoat { get; set; }
|
||||
|
||||
[Range(0, 10000)]
|
||||
[Display(Name = "Coverage (Sq Ft / Lb)")]
|
||||
public decimal? CoverageSqFtPerLb { get; set; }
|
||||
|
||||
[Range(0, 100)]
|
||||
[Display(Name = "Specific Gravity")]
|
||||
public decimal? SpecificGravity { get; set; }
|
||||
|
||||
[Range(0, 100)]
|
||||
[Display(Name = "Transfer Efficiency (%)")]
|
||||
public decimal? TransferEfficiency { get; set; }
|
||||
|
||||
[Display(Name = "Discontinued")]
|
||||
public bool IsDiscontinued { get; set; }
|
||||
|
||||
[Display(Name = "User Contributed")]
|
||||
public bool IsUserContributed { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
|
||||
namespace PowderCoating.Web.ViewModels.PowderCatalog;
|
||||
|
||||
public class PowderCatalogIndexViewModel
|
||||
{
|
||||
public PowderCatalogStatsDto Stats { get; set; } = new();
|
||||
public PagedResult<PowderCatalogListItemViewModel> Catalog { get; set; } = new();
|
||||
public IReadOnlyList<string> Vendors { get; set; } = Array.Empty<string>();
|
||||
|
||||
public string? SearchTerm { get; set; }
|
||||
public string? VendorName { get; set; }
|
||||
public string Status { get; set; } = "all";
|
||||
public string Source { get; set; } = "all";
|
||||
public string Completeness { get; set; } = "all";
|
||||
public string SortColumn { get; set; } = "VendorName";
|
||||
public string SortDirection { get; set; } = "asc";
|
||||
}
|
||||
|
||||
public class PowderCatalogListItemViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public string ColorName { get; set; } = string.Empty;
|
||||
public string? Finish { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IsDiscontinued { get; set; }
|
||||
public bool IsUserContributed { get; set; }
|
||||
public bool HasImage { get; set; }
|
||||
public bool HasCoreSpecs { get; set; }
|
||||
public bool HasDocuments { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
}
|
||||
@@ -479,7 +479,7 @@
|
||||
<i class="bi bi-bag-plus-fill me-2 text-muted"></i>Powder in Queue to be Ordered
|
||||
<span class="ms-2 text-muted fw-normal small">@Model.PowderOrdersNeededCount item@(Model.PowderOrdersNeededCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Mark lines as ordered to remove them</small>
|
||||
<small class="text-muted">Grouped by vendor · Mark lines as ordered to remove them</small>
|
||||
</div>
|
||||
<div class="card-body pt-0 pb-3">
|
||||
@foreach (var vendorGroup in Model.PowderOrdersNeeded)
|
||||
@@ -574,7 +574,7 @@
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
</div>
|
||||
<div class="card-body pt-0 pb-3" id="placed-card-body">
|
||||
@if (Model.PowderOrdersPlaced.Any())
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
@if (Model.ExpiryDate.HasValue)
|
||||
{
|
||||
<span class="ms-2 small">· 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>
|
||||
|
||||
@@ -96,6 +96,35 @@
|
||||
patterns in your job volume and revenue.
|
||||
</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Sales Tax Report</h3>
|
||||
<p>
|
||||
An invoice-basis Sales Tax Liability report — shows what you collected in tax during the
|
||||
period and breaks it down so you can file accurately. Key figures:
|
||||
</p>
|
||||
<ul class="mb-3">
|
||||
<li class="mb-1"><strong>Total Tax Billed</strong> — sum of all tax charged on invoices in the period.</li>
|
||||
<li class="mb-1"><strong>Taxable Sales</strong> — subtotals of invoices where tax was charged.</li>
|
||||
<li class="mb-1"><strong>Non-Taxable Sales</strong> — subtotals of tax-exempt invoices (e.g. tax-exempt customers).</li>
|
||||
<li class="mb-1"><strong>Effective Tax Rate</strong> — overall average rate across all taxable invoices.</li>
|
||||
<li class="mb-1"><strong>By Tax Account</strong> — breakdown by GL account (useful if you have multiple tax jurisdictions or rates).</li>
|
||||
<li class="mb-1"><strong>By Month</strong> — month-by-month chart and table of taxable sales and tax billed.</li>
|
||||
<li class="mb-1"><strong>Invoice Detail</strong> — every invoice in the period with its tax %, tax amount, and tax account. Non-taxable invoices appear shaded grey so they are easy to distinguish.</li>
|
||||
</ul>
|
||||
<p>
|
||||
This report supports both <strong>PDF export</strong> and <strong>CSV export</strong>.
|
||||
The CSV is formatted for handing to your accountant or importing into tax-filing software —
|
||||
one row per invoice with all relevant columns. Use the quick preset buttons (This Month,
|
||||
Last Month, YTD, Last Year) to jump to common filing periods without manually entering dates.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
|
||||
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Invoice basis vs. cash basis:</strong> The Sales Tax Report counts tax when an
|
||||
invoice is created, not when it is paid. If your jurisdiction requires cash-basis reporting,
|
||||
cross-reference payments using the <strong>Sales & Income</strong> report.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">Revenue Trends</h3>
|
||||
<p>
|
||||
A charting view of monthly and quarterly revenue over time. Useful for year-over-year
|
||||
@@ -242,12 +271,23 @@
|
||||
|
||||
<section id="pdf-export" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-file-earmark-pdf text-primary me-2"></i>PDF Export
|
||||
<i class="bi bi-file-earmark-pdf text-primary me-2"></i>PDF & CSV Export
|
||||
</h2>
|
||||
<p>
|
||||
Most financial reports (P&L, Balance Sheet, AR Aging, and others) include a
|
||||
<strong>Download PDF</strong> button. Use this to generate a print-ready version for your
|
||||
accountant, a business review, or your own records.
|
||||
Most financial reports (P&L, Balance Sheet, AR Aging, Sales & Income, and others)
|
||||
include a <strong>Download PDF</strong> button. Use this to generate a print-ready version
|
||||
for your accountant, a business review, or your own records.
|
||||
</p>
|
||||
<p>
|
||||
The <strong>Sales Tax Report</strong> also includes an <strong>Export CSV</strong> button.
|
||||
The CSV file contains one row per invoice and is formatted so it can be opened directly in
|
||||
Excel or imported into most tax-filing and accounting packages. Column headers match standard
|
||||
tax report terminology: Invoice #, Customer, Date, Status, Subtotal, Tax %, Tax Amount,
|
||||
Total, Amount Paid, Balance Due, Tax Account.
|
||||
</p>
|
||||
<p>
|
||||
All PDF and CSV exports respect the same date range you have selected in the report — what
|
||||
you see on screen is exactly what gets exported.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -261,9 +301,10 @@
|
||||
<nav class="nav flex-column">
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#financial-reports">Financial Reports</a>
|
||||
<a class="nav-link py-1 px-3 small text-body ps-4" href="#financial-reports" style="font-size:.75rem">Sales Tax Report</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#operations-reports">Operations Reports</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#ai-reports">AI-Powered Reports</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#pdf-export">PDF Export</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#pdf-export">PDF & CSV Export</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,21 +159,35 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-coverage">
|
||||
<div class="col-md-4" id="wrap-coverage">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Coverage"
|
||||
data-bs-content="How many square feet one pound of this powder covers at a standard film thickness. Industry average is about 30 sq ft/lb. Check the manufacturer's technical data sheet for the exact value. This is used together with Transfer Efficiency to calculate how much powder to order for a job.">
|
||||
data-bs-content="Manufacturer theoretical coverage for this powder, typically based on about 1.5 mil film thickness. Many powders land around 70 to 120 sq ft/lb. Used together with Transfer Efficiency to calculate how much powder to order for a job.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" value="30" class="form-control" id="field-coverage" placeholder="30" />
|
||||
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" value="30" class="form-control" id="field-coverage" placeholder="e.g., 78" />
|
||||
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Surface area coverage per unit of weight (default: 30)</small>
|
||||
<small class="form-text text-muted">Theoretical coverage from the TDS, usually expressed in sq ft/lb</small>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-transfer">
|
||||
<div class="col-md-4" id="wrap-specificgravity">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="SpecificGravity" class="form-label mb-0">Specific Gravity</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Specific Gravity"
|
||||
data-bs-content="Specific gravity from the powder's technical data sheet. This is useful reference data on its own and can also be used to derive theoretical coverage when the TDS omits a direct coverage number.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<input asp-for="SpecificGravity" type="number" step="0.01" min="0" class="form-control" id="field-specificgravity" placeholder="e.g., 1.65" />
|
||||
<span asp-validation-for="SpecificGravity" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Store the TDS specific gravity for future reference and calculations</small>
|
||||
</div>
|
||||
<div class="col-md-4" id="wrap-transfer">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="TransferEfficiency" class="form-label mb-0">Transfer Efficiency (%)</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
|
||||
@@ -161,21 +161,35 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-coverage">
|
||||
<div class="col-md-4" id="wrap-coverage">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="CoverageSqFtPerLb" class="form-label mb-0">Coverage (@ViewBag.CoverageUnit)</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Coverage"
|
||||
data-bs-content="How many square feet one pound covers at a standard film thickness. Industry average is about 30 sq ft/lb — check the manufacturer's tech data sheet for the exact figure. Used together with Transfer Efficiency to calculate powder to order for each job.">
|
||||
data-bs-content="Manufacturer theoretical coverage for this powder, typically based on about 1.5 mil film thickness. Many powders land around 70 to 120 sq ft/lb. Used together with Transfer Efficiency to calculate powder to order for each job.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" class="form-control" id="field-coverage" placeholder="30" />
|
||||
<input asp-for="CoverageSqFtPerLb" type="number" step="0.01" min="0" class="form-control" id="field-coverage" placeholder="e.g., 78" />
|
||||
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Surface area coverage per unit of weight (default: 30)</small>
|
||||
<small class="form-text text-muted">Theoretical coverage from the TDS, usually expressed in sq ft/lb</small>
|
||||
</div>
|
||||
<div class="col-md-6" id="wrap-transfer">
|
||||
<div class="col-md-4" id="wrap-specificgravity">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="SpecificGravity" class="form-label mb-0">Specific Gravity</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Specific Gravity"
|
||||
data-bs-content="Specific gravity from the powder's technical data sheet. This is useful reference data on its own and can also be used to derive theoretical coverage when the TDS omits a direct coverage number.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<input asp-for="SpecificGravity" type="number" step="0.01" min="0" class="form-control" id="field-specificgravity" placeholder="e.g., 1.65" />
|
||||
<span asp-validation-for="SpecificGravity" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Store the TDS specific gravity for future reference and calculations</small>
|
||||
</div>
|
||||
<div class="col-md-4" id="wrap-transfer">
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<label asp-for="TransferEfficiency" class="form-label mb-0">Transfer Efficiency (%)</label>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
return catId && coatingMap[String(catId)] === true;
|
||||
}
|
||||
|
||||
const coatingOnlyFields = ['wrap-colorname', 'wrap-colorcode', 'wrap-finish', 'wrap-coverage', 'wrap-transfer'];
|
||||
const coatingOnlyFields = ['wrap-colorname', 'wrap-colorcode', 'wrap-finish', 'wrap-coverage', 'wrap-specificgravity', 'wrap-transfer'];
|
||||
const colorNameLabel = document.querySelector('#wrap-colorname label');
|
||||
|
||||
function updateCoatingVisibility(catId) {
|
||||
@@ -403,6 +403,7 @@
|
||||
// Product details
|
||||
fillIf('field-finish', data.finish, 'Finish');
|
||||
fillIf('field-coverage', data.coverageSqFtPerLb, 'Coverage');
|
||||
fillIf('field-specificgravity', data.specificGravity, 'Specific Gravity');
|
||||
fillIf('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
|
||||
|
||||
// Coating specs
|
||||
|
||||
@@ -366,7 +366,7 @@
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||
{
|
||||
<text> · @coat.Finish</text>
|
||||
<text> · @coat.Finish</text>
|
||||
}
|
||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
@@ -493,7 +493,7 @@
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||
{
|
||||
<text> · @coat.Finish</text>
|
||||
<text> · @coat.Finish</text>
|
||||
}
|
||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
@@ -1680,12 +1680,46 @@
|
||||
<p class="mb-1"><strong>Uploaded:</strong> <span id="photoDetailDate"></span></p>
|
||||
<p class="mb-0"><strong>By:</strong> <span id="photoDetailUploader"></span></p>
|
||||
</div>
|
||||
<!-- Edit form (hidden by default) -->
|
||||
<div id="photoEditPanel" class="d-none text-start mt-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Photo Type</label>
|
||||
<select class="form-select" id="editPhotoType">
|
||||
<option value="0">Before</option>
|
||||
<option value="1">Progress</option>
|
||||
<option value="2">After</option>
|
||||
<option value="3">Quality Check</option>
|
||||
<option value="4">Issue</option>
|
||||
<option value="5">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Caption / Note</label>
|
||||
<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>
|
||||
<input type="hidden" id="editPhotoTagsHidden" />
|
||||
<div id="editPhotoTagsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.deletePhoto()">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<div id="viewModeButtons" class="d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="jobPhotoModule.editPhoto()">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.deletePhoto()">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
<div id="editModeButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-secondary" onclick="jobPhotoModule.cancelPhotoEdit()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.savePhotoEdit()">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1747,7 +1781,7 @@
|
||||
<small>@item.Description</small>
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
||||
<span class="badge bg-secondary ms-1">×@item.Quantity</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@@ -1940,7 +1974,7 @@
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
@@ -2666,7 +2700,7 @@
|
||||
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 × ${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>';
|
||||
|
||||
@@ -2675,7 +2709,7 @@
|
||||
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-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-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>';
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
@foreach (var item in job.Items)
|
||||
{
|
||||
<li class="d-flex align-items-start gap-2 mb-1">
|
||||
<span class="badge bg-secondary">×@item.Quantity</span>
|
||||
<span class="badge bg-secondary">×@item.Quantity</span>
|
||||
<span class="small">@item.Description</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Add Powder Catalog Item";
|
||||
ViewData["PageIcon"] = "bi-plus-circle";
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-plus-circle me-2 text-primary"></i>Add Powder Catalog Item</h4>
|
||||
<small class="text-muted">Create a platform-level powder record for inventory autofill and documentation links.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-9">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<partial name="_Form" model="Model" />
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Powder Catalog Item";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-pencil-square me-2 text-primary"></i>Edit Powder Catalog Item</h4>
|
||||
<small class="text-muted">@Model.VendorName - @Model.Sku</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-xl-9">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<input asp-for="CreatedAt" type="hidden" />
|
||||
<input asp-for="UpdatedAt" type="hidden" />
|
||||
<input asp-for="LastSyncedAt" type="hidden" />
|
||||
@{
|
||||
ViewData["EnableAiLookup"] = true;
|
||||
}
|
||||
<partial name="_Form" model="Model" />
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Changes
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
<script src="~/js/powder-catalog-ai-lookup.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -1,12 +1,94 @@
|
||||
@model PowderCoating.Application.DTOs.Inventory.PowderCatalogStatsDto
|
||||
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogIndexViewModel
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Powder Catalog";
|
||||
ViewData["PageIcon"] = "bi-palette2";
|
||||
Layout = "_Layout";
|
||||
ViewData["PageHelpTitle"] = "Powder Catalog";
|
||||
ViewData["PageHelpContent"] = "Manage the platform-level powder master list used to auto-fill inventory. Filter for contributed records, missing specs, or discontinued powders, then edit entries directly from here.";
|
||||
}
|
||||
|
||||
<div class="container-fluid">
|
||||
@functions {
|
||||
string SortLink(string column)
|
||||
{
|
||||
var route = new Dictionary<string, object?>
|
||||
{
|
||||
["searchTerm"] = Model.SearchTerm,
|
||||
["vendorName"] = Model.VendorName,
|
||||
["status"] = Model.Status,
|
||||
["source"] = Model.Source,
|
||||
["completeness"] = Model.Completeness,
|
||||
["pageNumber"] = 1,
|
||||
["pageSize"] = Model.Catalog.PageSize,
|
||||
["sortColumn"] = column,
|
||||
["sortDirection"] = Model.SortColumn == column && Model.SortDirection == "asc" ? "desc" : "asc"
|
||||
};
|
||||
return Url.Action("Index", route) ?? "#";
|
||||
}
|
||||
|
||||
string SortIcon(string column)
|
||||
{
|
||||
if (!string.Equals(Model.SortColumn, column, StringComparison.OrdinalIgnoreCase))
|
||||
return "bi-arrow-down-up";
|
||||
|
||||
return Model.SortDirection == "asc" ? "bi-arrow-up" : "bi-arrow-down";
|
||||
}
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
.powder-catalog-summary .card {
|
||||
border: 0;
|
||||
box-shadow: 0 .125rem .5rem rgba(15, 23, 42, .08);
|
||||
}
|
||||
|
||||
.powder-catalog-summary .metric-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .table th a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .powder-name {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .quality-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .quality-stack .badge,
|
||||
.powder-catalog-grid .status-stack .badge {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.powder-catalog-grid .table td,
|
||||
.powder-catalog-grid .table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .powder-catalog-grid .table-light th,
|
||||
[data-bs-theme="dark"] .powder-catalog-grid .table-light td {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show mb-3" role="alert">
|
||||
@@ -22,75 +104,93 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">
|
||||
<div>
|
||||
<h4 class="mb-1"><i class="bi bi-palette2 me-2 text-primary"></i>Powder Catalog</h4>
|
||||
<div class="text-muted small">Platform-level lookup library for inventory autofill, SDS/TDS links, and curing specs.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Powder
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4 powder-catalog-summary">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-3 bg-primary bg-opacity-10">
|
||||
<i class="bi bi-collection fs-4 text-primary"></i>
|
||||
<div class="metric-icon text-primary" style="background:rgba(13,110,253,.12);">
|
||||
<i class="bi bi-collection fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.TotalProducts.ToString("N0")</div>
|
||||
<div class="text-muted small">Total Products</div>
|
||||
<div class="fs-4 fw-bold">@Model.Stats.TotalProducts.ToString("N0")</div>
|
||||
<div class="small text-muted">Total Products</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-3 bg-success bg-opacity-10">
|
||||
<i class="bi bi-check-circle fs-4 text-success"></i>
|
||||
<div class="metric-icon text-success" style="background:rgba(25,135,84,.12);">
|
||||
<i class="bi bi-check-circle fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.ActiveProducts.ToString("N0")</div>
|
||||
<div class="text-muted small">Active</div>
|
||||
<div class="fs-4 fw-bold">@Model.Stats.ActiveProducts.ToString("N0")</div>
|
||||
<div class="small text-muted">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-3 bg-warning bg-opacity-10">
|
||||
<i class="bi bi-slash-circle fs-4 text-warning"></i>
|
||||
<div class="metric-icon text-warning" style="background:rgba(255,193,7,.18);">
|
||||
<i class="bi bi-slash-circle fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.DiscontinuedProducts.ToString("N0")</div>
|
||||
<div class="text-muted small">Discontinued</div>
|
||||
<div class="fs-4 fw-bold">@Model.Stats.DiscontinuedProducts.ToString("N0")</div>
|
||||
<div class="small text-muted">Discontinued</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-3 bg-info bg-opacity-10">
|
||||
<i class="bi bi-building fs-4 text-info"></i>
|
||||
<div class="metric-icon text-info" style="background:rgba(13,202,240,.14);">
|
||||
<i class="bi bi-building fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.VendorCount</div>
|
||||
<div class="text-muted small">
|
||||
@(Model.VendorCount == 1 ? "Vendor" : "Vendors")
|
||||
@if (Model.LastImportedAt.HasValue)
|
||||
{
|
||||
<br /><span class="text-muted" style="font-size:.75rem;">Last sync @Model.LastImportedAt.Value.ToString("MMM d, yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
<div class="fs-4 fw-bold">@Model.Stats.VendorCount</div>
|
||||
<div class="small text-muted">Vendors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-3 bg-purple bg-opacity-10" style="background:rgba(111,66,193,.1)">
|
||||
<i class="bi bi-qr-code-scan fs-4" style="color:#6f42c1;"></i>
|
||||
<div class="metric-icon" style="background:rgba(111,66,193,.14); color:#6f42c1;">
|
||||
<i class="bi bi-qr-code-scan fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-3 fw-bold">@Model.UserContributedProducts.ToString("N0")</div>
|
||||
<div class="text-muted small">Tenant Contributed</div>
|
||||
<div class="fs-4 fw-bold">@Model.Stats.UserContributedProducts.ToString("N0")</div>
|
||||
<div class="small text-muted">Contributed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="metric-icon text-secondary" style="background:rgba(108,117,125,.14);">
|
||||
<i class="bi bi-arrow-repeat fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">@(Model.Stats.LastImportedAt?.ToString("MMM d, yyyy") ?? "Never")</div>
|
||||
<div class="small text-muted">Last Sync</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,50 +198,271 @@
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Import card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="col-xl-8">
|
||||
<div class="card border-0 shadow-sm powder-catalog-grid">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h5 class="mb-1"><i class="bi bi-list-ul me-2 text-primary"></i>Manage Catalog Records</h5>
|
||||
<div class="small text-muted">Search, filter, and edit the powders your inventory lookup depends on.</div>
|
||||
</div>
|
||||
<div class="small text-muted">@Model.Catalog.TotalCount.ToString("N0") filtered result@(Model.Catalog.TotalCount == 1 ? "" : "s")</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small mb-1">Search</label>
|
||||
<input type="text" name="searchTerm" class="form-control form-control-sm" value="@Model.SearchTerm" placeholder="Vendor, SKU, color, finish..." />
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Vendor</label>
|
||||
<select name="vendorName" class="form-select form-select-sm">
|
||||
<option value="">All Vendors</option>
|
||||
@foreach (var vendor in Model.Vendors)
|
||||
{
|
||||
<option value="@vendor" selected="@(string.Equals(Model.VendorName, vendor, StringComparison.OrdinalIgnoreCase))">@vendor</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="all" selected="@(Model.Status == "all")">All</option>
|
||||
<option value="active" selected="@(Model.Status == "active")">Active</option>
|
||||
<option value="discontinued" selected="@(Model.Status == "discontinued")">Discontinued</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Source</label>
|
||||
<select name="source" class="form-select form-select-sm">
|
||||
<option value="all" selected="@(Model.Source == "all")">All</option>
|
||||
<option value="curated" selected="@(Model.Source == "curated")">Curated</option>
|
||||
<option value="contributed" selected="@(Model.Source == "contributed")">Contributed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small mb-1">Completeness</label>
|
||||
<select name="completeness" class="form-select form-select-sm">
|
||||
<option value="all" selected="@(Model.Completeness == "all")">All</option>
|
||||
<option value="ready" selected="@(Model.Completeness == "ready")">Ready</option>
|
||||
<option value="missing-specs" selected="@(Model.Completeness == "missing-specs")">Missing Specs</option>
|
||||
<option value="missing-docs" selected="@(Model.Completeness == "missing-docs")">Missing Docs</option>
|
||||
<option value="missing-image" selected="@(Model.Completeness == "missing-image")">Missing Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="sortColumn" value="@Model.SortColumn" />
|
||||
<input type="hidden" name="sortDirection" value="@Model.SortDirection" />
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-funnel me-1"></i>Apply Filters
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th><a href="@SortLink("VendorName")" class="text-decoration-none">Vendor <i class="bi @SortIcon("VendorName")"></i></a></th>
|
||||
<th><a href="@SortLink("Sku")" class="text-decoration-none">SKU <i class="bi @SortIcon("Sku")"></i></a></th>
|
||||
<th class="powder-name"><a href="@SortLink("ColorName")" class="text-decoration-none">Powder <i class="bi @SortIcon("ColorName")"></i></a></th>
|
||||
<th><a href="@SortLink("Finish")" class="text-decoration-none">Finish <i class="bi @SortIcon("Finish")"></i></a></th>
|
||||
<th><a href="@SortLink("UnitPrice")" class="text-decoration-none">Price <i class="bi @SortIcon("UnitPrice")"></i></a></th>
|
||||
<th>Status</th>
|
||||
<th>Quality</th>
|
||||
<th><a href="@SortLink("LastSyncedAt")" class="text-decoration-none">Synced <i class="bi @SortIcon("LastSyncedAt")"></i></a></th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (!Model.Catalog.Items.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
|
||||
No powder catalog records matched your filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in Model.Catalog.Items)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-medium">@item.VendorName</td>
|
||||
<td><code>@item.Sku</code></td>
|
||||
<td>
|
||||
<div class="fw-semibold">@item.ColorName</div>
|
||||
<div class="small text-muted">Updated @(item.UpdatedAt?.ToString("MMM d, yyyy") ?? item.CreatedAt.ToString("MMM d, yyyy"))</div>
|
||||
</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(item.Finish) ? "-" : item.Finish)</td>
|
||||
<td>@(item.UnitPrice > 0 ? item.UnitPrice.ToString("C") : "-")</td>
|
||||
<td>
|
||||
<div class="status-stack">
|
||||
@if (item.IsDiscontinued)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Discontinued</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
@if (item.IsUserContributed)
|
||||
{
|
||||
<span class="badge bg-info text-dark">Contributed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Curated</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="quality-stack">
|
||||
<span class="badge @(item.HasCoreSpecs ? "bg-success-subtle text-success border" : "bg-danger-subtle text-danger border")">Specs</span>
|
||||
<span class="badge @(item.HasDocuments ? "bg-success-subtle text-success border" : "bg-danger-subtle text-danger border")">Docs</span>
|
||||
<span class="badge @(item.HasImage ? "bg-success-subtle text-success border" : "bg-warning-subtle text-warning border")">Image</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="small text-muted">@(item.LastSyncedAt?.ToString("MMM d, yyyy") ?? "-")</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form asp-action="ToggleDiscontinued" asp-route-id="@item.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-@(item.IsDiscontinued ? "success" : "warning")" title="@(item.IsDiscontinued ? "Reactivate" : "Mark discontinued")">
|
||||
<i class="bi @(item.IsDiscontinued ? "bi-arrow-counterclockwise" : "bi-slash-circle")"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-view mt-3">
|
||||
@if (!Model.Catalog.Items.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-4">No powder catalog records matched your filters.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var item in Model.Catalog.Items)
|
||||
{
|
||||
<div class="mobile-data-card">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon @(item.IsDiscontinued ? "bg-warning" : "bg-primary")">
|
||||
<i class="bi bi-palette2"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@item.ColorName</h6>
|
||||
<small>@item.VendorName - @item.Sku</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Finish</span>
|
||||
<span class="mobile-card-value">@(string.IsNullOrWhiteSpace(item.Finish) ? "-" : item.Finish)</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Price</span>
|
||||
<span class="mobile-card-value">@(item.UnitPrice > 0 ? item.UnitPrice.ToString("C") : "-")</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Status</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (item.IsDiscontinued)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Discontinued</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
@if (item.IsUserContributed)
|
||||
{
|
||||
<span class="badge bg-info text-dark ms-1">Contributed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Quality</span>
|
||||
<span class="mobile-card-value">@(item.HasCoreSpecs ? "Specs" : "Missing Specs"), @(item.HasDocuments ? "Docs" : "Missing Docs"), @(item.HasImage ? "Image" : "Missing Image")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<form asp-action="ToggleDiscontinued" asp-route-id="@item.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-@(item.IsDiscontinued ? "success" : "warning")">
|
||||
<i class="bi @(item.IsDiscontinued ? "bi-arrow-counterclockwise" : "bi-slash-circle") me-1"></i>@(item.IsDiscontinued ? "Reactivate" : "Discontinue")
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Catalog.TotalPages > 1)
|
||||
{
|
||||
<div class="card-footer bg-white">
|
||||
<partial name="_Pagination" model="Model.Catalog" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h5 class="mb-0"><i class="bi bi-cloud-upload me-2 text-primary"></i>Import Catalog Data</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Upload a Prismatic Powders scrape JSON file (the <code>prismatic_powders.json</code> format with
|
||||
a top-level <code>results</code> array). Existing SKUs are updated in-place; new ones are inserted.
|
||||
Discontinued products remain in the catalog flagged as <code>IsDiscontinued</code>.
|
||||
Upload a Prismatic-style scrape JSON file with a top-level <code>results</code> array.
|
||||
Existing rows are updated by vendor + SKU and new powders are inserted automatically.
|
||||
</p>
|
||||
<form asp-action="Import" method="post" enctype="multipart/form-data">
|
||||
<form asp-action="Import" method="post" enctype="multipart/form-data" id="powder-catalog-import-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Vendor Name</label>
|
||||
<input type="text" name="vendorName" value="Prismatic Powders" class="form-control" required />
|
||||
<div class="form-text">Must match exactly — used as the upsert key alongside SKU.</div>
|
||||
<div class="form-text">Used as part of the upsert key alongside SKU.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">JSON File <span class="text-danger">*</span></label>
|
||||
<input type="file" name="file" accept=".json" class="form-control" required />
|
||||
<div class="form-text">Max 50 MB. Must be the scraped format with <code>results[]</code> array.</div>
|
||||
<div class="form-text">Max 50 MB. Must contain the scraped <code>results[]</code> payload.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="btn-import">
|
||||
<i class="bi bi-upload me-2"></i>Import
|
||||
<button type="submit" class="btn btn-primary w-100" id="btn-import">
|
||||
<i class="bi bi-upload me-2"></i>Import Catalog
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info / how it works card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>How It Works</h5>
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2 text-primary"></i>Management Notes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled mb-0" style="line-height:2;">
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Platform-level:</strong> One shared catalog, no per-tenant copies.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Catalog-first lookup:</strong> When a tenant adds inventory, the form searches here before calling the AI API.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Auto-fill:</strong> Selecting a result fills color name, manufacturer, part number, unit cost, SDS/TDS links, and product image.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued:</strong> Flagged <code>IsDiscontinued = true</code> — never hidden, always available for historical lookups.</li>
|
||||
<li><i class="bi bi-clock text-muted me-2"></i><strong>Phase 2:</strong> Monthly price sync + push to tenant inventory items.</li>
|
||||
<ul class="list-unstyled mb-0" style="line-height:1.9;">
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Contributed powders</strong> are auto-added from tenant lookups when a catalog match does not exist.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Specs matter</strong> because inventory autofill uses finish, cure data, coverage, and transfer efficiency when available.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Documents matter</strong> because the inventory form surfaces product, SDS, and TDS links directly from this catalog.</li>
|
||||
<li><i class="bi bi-check2 text-success me-2"></i><strong>Discontinued powders stay searchable</strong> so historical inventory and customer references still resolve.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,10 +470,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelector('form').addEventListener('submit', function () {
|
||||
var btn = document.getElementById('btn-import');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing…';
|
||||
});
|
||||
</script>
|
||||
@section Scripts {
|
||||
<script>
|
||||
document.getElementById('powder-catalog-import-form')?.addEventListener('submit', function () {
|
||||
var btn = document.getElementById('btn-import');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Importing...';
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
@model PowderCoating.Web.ViewModels.PowderCatalog.PowderCatalogFormViewModel
|
||||
|
||||
@{
|
||||
var enableAiLookup = ViewData["EnableAiLookup"] as bool? == true;
|
||||
}
|
||||
|
||||
@if (enableAiLookup)
|
||||
{
|
||||
<div class="card border-0 bg-light-subtle mb-4">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-stars me-2 text-primary"></i>AI Lookup
|
||||
</h6>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="powder-ai-lookup-btn">
|
||||
<i class="bi bi-search me-1"></i>Search Missing Info
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="powder-ai-url-btn">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Use Product URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="small text-muted mb-2">
|
||||
Search the web for missing specs, cure data, and SDS/TDS links. Existing values are left alone unless the field is blank.
|
||||
</div>
|
||||
<div id="ai-lookup-status" class="alert alert-info d-none py-2 small mb-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3 small"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="VendorName" class="form-label fw-medium"></label>
|
||||
<input asp-for="VendorName" class="form-control" id="field-vendorname" />
|
||||
<span asp-validation-for="VendorName" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="Sku" class="form-label fw-medium"></label>
|
||||
<input asp-for="Sku" class="form-control" id="field-sku" />
|
||||
<span asp-validation-for="Sku" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="UnitPrice" class="form-label fw-medium"></label>
|
||||
<input asp-for="UnitPrice" class="form-control" id="field-unitprice" />
|
||||
<span asp-validation-for="UnitPrice" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label asp-for="ColorName" class="form-label fw-medium"></label>
|
||||
<input asp-for="ColorName" class="form-control" id="field-colorname" />
|
||||
<span asp-validation-for="ColorName" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label asp-for="Description" class="form-label fw-medium"></label>
|
||||
<textarea asp-for="Description" class="form-control" id="field-description" rows="3"></textarea>
|
||||
<span asp-validation-for="Description" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Finish" class="form-label fw-medium"></label>
|
||||
<input asp-for="Finish" class="form-control" id="field-finish" placeholder="Gloss, Matte, Satin, Metallic..." />
|
||||
<span asp-validation-for="Finish" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ColorFamilies" class="form-label fw-medium"></label>
|
||||
<input asp-for="ColorFamilies" class="form-control" id="field-colorfamilies" placeholder="Blue, Purple, Metallic" />
|
||||
<span asp-validation-for="ColorFamilies" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label asp-for="CureTemperatureF" class="form-label fw-medium"></label>
|
||||
<input asp-for="CureTemperatureF" class="form-control" id="field-curetemp" />
|
||||
<span asp-validation-for="CureTemperatureF" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="CureTimeMinutes" class="form-label fw-medium"></label>
|
||||
<input asp-for="CureTimeMinutes" class="form-control" id="field-curetime" />
|
||||
<span asp-validation-for="CureTimeMinutes" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="CoverageSqFtPerLb" class="form-label fw-medium"></label>
|
||||
<input asp-for="CoverageSqFtPerLb" class="form-control" id="field-coverage" />
|
||||
<span asp-validation-for="CoverageSqFtPerLb" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="SpecificGravity" class="form-label fw-medium"></label>
|
||||
<input asp-for="SpecificGravity" class="form-control" id="field-specificgravity" />
|
||||
<span asp-validation-for="SpecificGravity" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="TransferEfficiency" class="form-label fw-medium"></label>
|
||||
<input asp-for="TransferEfficiency" class="form-control" id="field-transfer" />
|
||||
<span asp-validation-for="TransferEfficiency" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProductUrl" class="form-label fw-medium"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="ProductUrl" class="form-control" id="field-producturl" />
|
||||
<a id="field-producturl-link" href="@Model.ProductUrl" target="_blank"
|
||||
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.ProductUrl) ? "d-none" : "")" title="Open Product URL">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<span asp-validation-for="ProductUrl" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ImageUrl" class="form-label fw-medium"></label>
|
||||
<input asp-for="ImageUrl" class="form-control" id="field-imageurl" />
|
||||
<span asp-validation-for="ImageUrl" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label asp-for="SdsUrl" class="form-label fw-medium"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="SdsUrl" class="form-control" id="field-sdsurl" />
|
||||
<a id="field-sdsurl-link" href="@Model.SdsUrl" target="_blank"
|
||||
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.SdsUrl) ? "d-none" : "")" title="Open SDS URL">
|
||||
<i class="bi bi-file-earmark-pdf"></i>
|
||||
</a>
|
||||
</div>
|
||||
<span asp-validation-for="SdsUrl" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="TdsUrl" class="form-label fw-medium"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="TdsUrl" class="form-control" id="field-tdsurl" />
|
||||
<a id="field-tdsurl-link" href="@Model.TdsUrl" target="_blank"
|
||||
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.TdsUrl) ? "d-none" : "")" title="Open TDS URL">
|
||||
<i class="bi bi-file-earmark-text"></i>
|
||||
</a>
|
||||
</div>
|
||||
<span asp-validation-for="TdsUrl" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="ApplicationGuideUrl" class="form-label fw-medium"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="ApplicationGuideUrl" class="form-control" id="field-applicationguideurl" />
|
||||
<a id="field-applicationguideurl-link" href="@Model.ApplicationGuideUrl" target="_blank"
|
||||
class="btn btn-outline-secondary @(string.IsNullOrWhiteSpace(Model.ApplicationGuideUrl) ? "d-none" : "")" title="Open Application Guide URL">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<span asp-validation-for="ApplicationGuideUrl" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label asp-for="RequiresClearCoat" class="form-label fw-medium"></label>
|
||||
<select asp-for="RequiresClearCoat" class="form-select" id="field-clearcoat">
|
||||
<option value="">Unknown</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
<span asp-validation-for="RequiresClearCoat" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input asp-for="IsDiscontinued" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsDiscontinued" class="form-check-label"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input asp-for="IsUserContributed" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsUserContributed" class="form-check-label"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Id > 0)
|
||||
{
|
||||
<hr class="my-4" />
|
||||
<div class="row g-3 small text-muted">
|
||||
<div class="col-md-4">
|
||||
<div class="fw-semibold text-body">Created</div>
|
||||
<div>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt") UTC</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="fw-semibold text-body">Updated</div>
|
||||
<div>@(Model.UpdatedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Never")@(Model.UpdatedAt.HasValue ? " UTC" : string.Empty)</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="fw-semibold text-body">Last Synced</div>
|
||||
<div>@(Model.LastSyncedAt?.ToString("MMM d, yyyy h:mm tt") ?? "Never")@(Model.LastSyncedAt.HasValue ? " UTC" : string.Empty)</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -398,7 +398,7 @@
|
||||
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn" onclick="if(typeof writeHiddenFields==='function')writeHiddenFields()">
|
||||
<i class="bi bi-check-circle me-1"></i>Create Quote
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -764,12 +764,31 @@
|
||||
</div>
|
||||
<div class="mt-2 small text-muted" id="qpDate"></div>
|
||||
<div class="mt-1 small text-muted text-truncate" id="qpFileName"></div>
|
||||
<div class="mt-2 small" id="qpCaptionRow" style="display:none;">
|
||||
<strong>Caption:</strong> <span id="qpCaption"></span>
|
||||
</div>
|
||||
<!-- Caption edit form (hidden by default) -->
|
||||
<div id="qpEditPanel" class="d-none text-start mt-3">
|
||||
<label class="form-label fw-semibold">Caption / Note</label>
|
||||
<textarea class="form-control" id="qpEditCaption" rows="2" placeholder="Add a caption or note..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" id="qpDeleteBtn" onclick="qpGallery.deletePhoto()">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<div id="qpViewButtons" class="d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-outline-secondary" id="qpEditBtn" onclick="qpGallery.editPhoto()">
|
||||
<i class="bi bi-pencil me-1"></i>Edit Caption
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="qpDeleteBtn" onclick="qpGallery.deletePhoto()">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
<div id="qpEditButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
|
||||
<button type="button" class="btn btn-secondary" onclick="qpGallery.cancelEdit()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="qpGallery.saveEdit()">
|
||||
<i class="bi bi-check-lg me-1"></i>Save Caption
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -782,13 +801,15 @@
|
||||
url = Url.Action("Photo", "Quotes", new { id = p.Id }),
|
||||
fileName = p.FileName,
|
||||
date = p.CreatedAt.ToString("MMM d, yyyy"),
|
||||
isAi = p.IsAiAnalysisPhoto
|
||||
isAi = p.IsAiAnalysisPhoto,
|
||||
caption = p.Caption
|
||||
})));
|
||||
|
||||
let currentIndex = 0;
|
||||
const quoteId = @Model.Id;
|
||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
|
||||
const token = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
function render() {
|
||||
@@ -799,8 +820,14 @@
|
||||
document.getElementById('qpFileName').textContent = p.fileName;
|
||||
document.getElementById('qpPosition').textContent = `Photo ${currentIndex + 1} of ${photos.length}`;
|
||||
document.getElementById('qpDeleteBtn').style.display = p.isAi ? 'none' : '';
|
||||
document.getElementById('qpEditBtn').style.display = p.isAi ? 'none' : '';
|
||||
document.getElementById('qpPrev').disabled = photos.length <= 1;
|
||||
document.getElementById('qpNext').disabled = photos.length <= 1;
|
||||
const captionRow = document.getElementById('qpCaptionRow');
|
||||
if (captionRow) {
|
||||
document.getElementById('qpCaption').textContent = p.caption || '';
|
||||
captionRow.style.display = p.caption ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function open(index) {
|
||||
@@ -810,10 +837,42 @@
|
||||
}
|
||||
|
||||
function navigate(dir) {
|
||||
const editPanel = document.getElementById('qpEditPanel');
|
||||
if (editPanel && !editPanel.classList.contains('d-none')) cancelEdit();
|
||||
currentIndex = (currentIndex + dir + photos.length) % photos.length;
|
||||
render();
|
||||
}
|
||||
|
||||
function editPhoto() {
|
||||
document.getElementById('qpEditCaption').value = photos[currentIndex].caption || '';
|
||||
document.getElementById('qpCaptionRow').style.display = 'none';
|
||||
document.getElementById('qpEditPanel').classList.remove('d-none');
|
||||
document.getElementById('qpViewButtons').classList.add('d-none');
|
||||
document.getElementById('qpEditButtons').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
document.getElementById('qpEditPanel').classList.add('d-none');
|
||||
document.getElementById('qpCaptionRow').style.display = photos[currentIndex].caption ? '' : 'none';
|
||||
document.getElementById('qpEditButtons').classList.add('d-none');
|
||||
document.getElementById('qpViewButtons').classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const p = photos[currentIndex];
|
||||
const caption = document.getElementById('qpEditCaption').value.trim();
|
||||
const fd = new FormData();
|
||||
fd.append('id', p.id);
|
||||
fd.append('caption', caption);
|
||||
fd.append('__RequestVerificationToken', token());
|
||||
const resp = await fetch(updateUrl, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (!data.success) { alert(data.error || 'Update failed.'); return; }
|
||||
p.caption = caption || null;
|
||||
cancelEdit();
|
||||
render();
|
||||
}
|
||||
|
||||
async function deletePhoto() {
|
||||
if (!confirm('Delete this photo?')) return;
|
||||
const p = photos[currentIndex];
|
||||
@@ -864,7 +923,7 @@
|
||||
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
|
||||
|
||||
const newIndex = photos.length;
|
||||
photos.push({ id: data.id, url: data.url, fileName: data.fileName, date: 'Just now', isAi: false });
|
||||
photos.push({ id: data.id, url: data.url, fileName: data.fileName, date: 'Just now', isAi: false, caption: null });
|
||||
|
||||
document.getElementById('noPhotosMsg')?.remove();
|
||||
const grid = document.getElementById('photoGrid');
|
||||
@@ -884,7 +943,13 @@
|
||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||||
}
|
||||
|
||||
return { open, navigate, deletePhoto };
|
||||
// Reset to view mode whenever the modal is closed
|
||||
document.getElementById('qpModal')?.addEventListener('hidden.bs.modal', () => {
|
||||
const editPanel = document.getElementById('qpEditPanel');
|
||||
if (editPanel && !editPanel.classList.contains('d-none')) cancelEdit();
|
||||
});
|
||||
|
||||
return { open, navigate, deletePhoto, editPhoto, cancelEdit, saveEdit };
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@@ -919,8 +984,7 @@
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>
|
||||
<i class="bi bi-fire me-1"></i>Oven
|
||||
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")
|
||||
× @Model.PricingBreakdown.OvenCycleMinutes min):
|
||||
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")@(Model.PricingBreakdown.OvenCycleMinutes > 0 ? $" × {Model.PricingBreakdown.OvenCycleMinutes} min" : "")):
|
||||
</span>
|
||||
<strong>@Model.PricingBreakdown.OvenBatchCost.ToString("C")</strong>
|
||||
</div>
|
||||
@@ -1083,7 +1147,7 @@
|
||||
@if (pb.OvenBatchCost > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Oven batch (@pb.OvenBatches batch@(pb.OvenBatches != 1 ? "es" : ""), @pb.OvenCycleMinutes min/cycle)</span>
|
||||
<span class="text-muted">Oven batch (@pb.OvenBatches batch@(pb.OvenBatches != 1 ? "es" : "")@(pb.OvenCycleMinutes > 0 ? $", {pb.OvenCycleMinutes} min/cycle" : ""))</span>
|
||||
<span>@pb.OvenBatchCost.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1225,7 +1289,7 @@
|
||||
{
|
||||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
||||
@if (item.SurfaceAreaSqFt > 0) { <text>@item.SurfaceAreaSqFt.ToString("F2") sqft</text> }
|
||||
@if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { <text> · </text> }
|
||||
@if (item.SurfaceAreaSqFt > 0 && item.EstimatedMinutes > 0) { <text> · </text> }
|
||||
@if (item.EstimatedMinutes > 0) { <text>@item.EstimatedMinutes min</text> }
|
||||
</span>
|
||||
}
|
||||
@@ -1233,7 +1297,7 @@
|
||||
<div class="text-end">
|
||||
@if (item.Quantity > 1)
|
||||
{
|
||||
<span class="text-muted me-1">×@item.Quantity.ToString("G29")</span>
|
||||
<span class="text-muted me-1">×@item.Quantity.ToString("G29")</span>
|
||||
}
|
||||
<span class="fw-semibold">@item.TotalPrice.ToString("C")</span>
|
||||
</div>
|
||||
|
||||
@@ -363,6 +363,10 @@
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Edit caption">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
}
|
||||
<a href="@Url.Action("Photo", "Quotes", new { id = photo.Id })" target="_blank" title="View full size">
|
||||
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
||||
@@ -372,6 +376,17 @@
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate mb-0" title="@photo.FileName">@photo.FileName</p>
|
||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">@photo.CreatedAt.ToString("MMM d, yyyy")</p>
|
||||
@if (!photo.IsAiAnalysisPhoto)
|
||||
{
|
||||
<p class="card-text small fst-italic text-truncate mb-0 caption-display" title="@photo.Caption">@photo.Caption</p>
|
||||
<div class="caption-edit-form d-none mt-1" data-photo-id="@photo.Id">
|
||||
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption...">@photo.Caption</textarea>
|
||||
<div class="d-flex gap-1 mt-1">
|
||||
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,7 +413,7 @@
|
||||
<a asp-action="Details" asp-controller="Quotes" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn" onclick="if(typeof writeHiddenFields==='function')writeHiddenFields()">
|
||||
<i class="bi bi-check-circle me-1"></i>Update Quote
|
||||
</button>
|
||||
</div>
|
||||
@@ -708,6 +723,7 @@
|
||||
const quoteId = @Model.Id;
|
||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
|
||||
const fileInput = document.getElementById('editPhotoFileInput');
|
||||
@@ -744,12 +760,24 @@
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary position-absolute top-0 start-0 m-1 p-0 px-1 edit-caption-btn"
|
||||
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Edit caption">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<a href="${data.url}" target="_blank" title="View full size">
|
||||
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
|
||||
</a>
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
|
||||
<p class="card-text text-muted mb-0" style="font-size:.7rem;">Just now</p>
|
||||
<p class="card-text small fst-italic text-truncate mb-0 caption-display"></p>
|
||||
<div class="caption-edit-form d-none mt-1" data-photo-id="${data.id}">
|
||||
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption..."></textarea>
|
||||
<div class="d-flex gap-1 mt-1">
|
||||
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
grid.appendChild(col);
|
||||
@@ -771,6 +799,45 @@
|
||||
updateCount(-1);
|
||||
});
|
||||
|
||||
// Show inline caption editor
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.edit-caption-btn');
|
||||
if (!btn) return;
|
||||
const card = btn.closest('.photo-item');
|
||||
card.querySelector('.caption-display')?.classList.add('d-none');
|
||||
card.querySelector('.caption-edit-form')?.classList.remove('d-none');
|
||||
});
|
||||
|
||||
// Cancel inline caption edit
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.cancel-caption-btn');
|
||||
if (!btn) return;
|
||||
const card = btn.closest('.photo-item');
|
||||
card.querySelector('.caption-edit-form')?.classList.add('d-none');
|
||||
card.querySelector('.caption-display')?.classList.remove('d-none');
|
||||
});
|
||||
|
||||
// Save caption
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.save-caption-btn');
|
||||
if (!btn) return;
|
||||
const card = btn.closest('.photo-item');
|
||||
const editForm = card.querySelector('.caption-edit-form');
|
||||
const photoId = editForm?.dataset.photoId;
|
||||
const caption = card.querySelector('.caption-textarea')?.value.trim() ?? '';
|
||||
const fd = new FormData();
|
||||
fd.append('id', photoId);
|
||||
fd.append('caption', caption);
|
||||
fd.append('__RequestVerificationToken', token);
|
||||
const resp = await fetch(updateUrl, { method: 'POST', body: fd });
|
||||
const data = await resp.json();
|
||||
if (!data.success) { alert(data.error || 'Update failed.'); return; }
|
||||
const displayEl = card.querySelector('.caption-display');
|
||||
if (displayEl) { displayEl.textContent = caption; displayEl.title = caption; }
|
||||
editForm?.classList.add('d-none');
|
||||
card.querySelector('.caption-display')?.classList.remove('d-none');
|
||||
});
|
||||
|
||||
function updateCount(delta) {
|
||||
const badge = document.getElementById('photoCount');
|
||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
||||
|
||||
@@ -212,6 +212,14 @@
|
||||
<p>Detailed breakdown of sales by customer, job type, and period.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="SalesTax" class="report-card">
|
||||
<div class="report-card-icon" style="background:#faf5ff;color:#7c3aed;">
|
||||
<i class="bi bi-percent"></i>
|
||||
</div>
|
||||
<h5>Sales Tax Report</h5>
|
||||
<p>Invoice-basis tax liability: taxable vs non-taxable sales, tax billed by account and month, full invoice detail.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="CustomerOverview" class="report-card">
|
||||
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
|
||||
<i class="bi bi-people"></i>
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.SalesTaxReportDto
|
||||
@{
|
||||
ViewData["Title"] = "Sales Tax Report";
|
||||
ViewData["PageIcon"] = "bi-percent";
|
||||
var today = DateTime.Today;
|
||||
var ytdFrom = new DateTime(today.Year, 1, 1).ToString("yyyy-MM-dd");
|
||||
var ytdTo = today.ToString("yyyy-MM-dd");
|
||||
var lastYrFrom = new DateTime(today.Year - 1, 1, 1).ToString("yyyy-MM-dd");
|
||||
var lastYrTo = new DateTime(today.Year - 1, 12, 31).ToString("yyyy-MM-dd");
|
||||
var thisMonthFrom = new DateTime(today.Year, today.Month, 1).ToString("yyyy-MM-dd");
|
||||
var thisMonthTo = today.ToString("yyyy-MM-dd");
|
||||
var lastMonthFrom = new DateTime(today.Year, today.Month, 1).AddMonths(-1).ToString("yyyy-MM-dd");
|
||||
var lastMonthTo = new DateTime(today.Year, today.Month, 1).AddDays(-1).ToString("yyyy-MM-dd");
|
||||
|
||||
var monthLabels = Model.ByMonth.Select(m => m.Label).ToList();
|
||||
var monthTaxable = Model.ByMonth.Select(m => m.TaxableSales).ToList();
|
||||
var monthTaxBilled = Model.ByMonth.Select(m => m.TaxBilled).ToList();
|
||||
}
|
||||
|
||||
<style>
|
||||
@@media print {
|
||||
.no-print { display: none !important; }
|
||||
.card { border: 1px solid #dee2e6 !important; box-shadow: none !important; }
|
||||
body { font-size: 11px; }
|
||||
}
|
||||
.row-nontaxable td { background-color: #f8f9fa !important; color: #6c757d; }
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-items-center gap-2 mb-3 no-print">
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<p class="text-muted mb-0">@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a href="@Url.Action("SalesTaxCsv", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-success no-print">
|
||||
<i class="bi bi-filetype-csv me-1"></i>Export CSV
|
||||
</a>
|
||||
<a href="@Url.Action("SalesTaxPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
|
||||
class="btn btn-sm btn-outline-danger no-print" target="_blank">
|
||||
<i class="bi bi-file-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a href="@Url.Action("SalesTaxPdf", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd"), inline = true })"
|
||||
class="btn btn-sm btn-outline-secondary no-print" target="_blank">
|
||||
<i class="bi bi-printer me-1"></i>Print
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date filter -->
|
||||
<div class="card shadow-sm mb-4 no-print">
|
||||
<div class="card-body py-3">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">From</label>
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm mb-1">To</label>
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-funnel me-1"></i>Run Report</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("SalesTax", new { from = thisMonthFrom, to = thisMonthTo })" class="btn btn-outline-secondary">This Month</a>
|
||||
<a href="@Url.Action("SalesTax", new { from = lastMonthFrom, to = lastMonthTo })" class="btn btn-outline-secondary">Last Month</a>
|
||||
<a href="@Url.Action("SalesTax", new { from = ytdFrom, to = ytdTo })" class="btn btn-outline-secondary">YTD</a>
|
||||
<a href="@Url.Action("SalesTax", new { from = lastYrFrom, to = lastYrTo })" class="btn btn-outline-secondary">Last Year</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print header -->
|
||||
<div class="text-center mb-4 d-none d-print-block">
|
||||
<h4 class="fw-bold">@Model.CompanyName</h4>
|
||||
<h5>Sales Tax Liability Report</h5>
|
||||
<p class="text-muted">@Model.From.ToString("MMMM d, yyyy") – @Model.To.ToString("MMMM d, yyyy") · Invoice Basis</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center h-100 border-primary border-top border-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 fw-bold text-primary mb-1">@Model.TotalTaxBilled.ToString("C")</div>
|
||||
<div class="text-muted small">Total Tax Billed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 fw-bold mb-1">@Model.TotalTaxableSales.ToString("C")</div>
|
||||
<div class="text-muted small">Taxable Sales</div>
|
||||
<div class="text-muted" style="font-size:0.7rem">@Model.TaxableInvoiceCount invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 fw-bold text-secondary mb-1">@Model.TotalNonTaxableSales.ToString("C")</div>
|
||||
<div class="text-muted small">Non-Taxable Sales</div>
|
||||
<div class="text-muted" style="font-size:0.7rem">@Model.NonTaxableInvoiceCount invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="h5 fw-bold text-success mb-1">@Model.EffectiveTaxRate.ToString("F2")%</div>
|
||||
<div class="text-muted small">Effective Tax Rate</div>
|
||||
<div class="text-muted" style="font-size:0.7rem">on taxable sales</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.Invoices.Any())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-receipt fs-1 d-block mb-2"></i>
|
||||
<p class="mb-0">No invoices found for this period.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-4 mb-4">
|
||||
<!-- Monthly trend chart -->
|
||||
@if (Model.ByMonth.Count > 1)
|
||||
{
|
||||
<div class="col-lg-8 no-print">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold">
|
||||
<i class="bi bi-bar-chart me-1"></i>Monthly Tax Trend
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="taxTrendChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- By Month table -->
|
||||
<div class="col-lg-@(Model.ByMonth.Count > 1 ? "4" : "12")">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-calendar3 me-1"></i>By Month</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th class="text-end">Taxable Sales</th>
|
||||
<th class="text-end">Tax Billed</th>
|
||||
<th class="text-center">#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var m in Model.ByMonth)
|
||||
{
|
||||
<tr>
|
||||
<td>@m.Label</td>
|
||||
<td class="text-end">@m.TaxableSales.ToString("C")</td>
|
||||
<td class="text-end text-primary fw-semibold">@m.TaxBilled.ToString("C")</td>
|
||||
<td class="text-center text-muted small">@m.InvoiceCount</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="text-end">@Model.TotalTaxableSales.ToString("C")</td>
|
||||
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
|
||||
<td class="text-center">@Model.TaxableInvoiceCount</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Tax Account -->
|
||||
@if (Model.ByAccount.Any())
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-bookmark me-1"></i>By Tax Account</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th class="text-end">Taxable Sales</th>
|
||||
<th class="text-end">Tax Billed</th>
|
||||
<th class="text-center">Invoices</th>
|
||||
<th class="text-end no-print">Effective Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in Model.ByAccount)
|
||||
{
|
||||
var rate = a.TaxableSales == 0 ? 0m : Math.Round(a.TaxBilled / a.TaxableSales * 100, 2);
|
||||
<tr>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(a.AccountNumber))
|
||||
{
|
||||
<span class="text-muted small me-1">@a.AccountNumber</span>
|
||||
}
|
||||
@a.AccountName
|
||||
</td>
|
||||
<td class="text-end">@a.TaxableSales.ToString("C")</td>
|
||||
<td class="text-end fw-semibold text-primary">@a.TaxBilled.ToString("C")</td>
|
||||
<td class="text-center text-muted small">@a.InvoiceCount</td>
|
||||
<td class="text-end text-muted small no-print">@rate.ToString("F2")%</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="text-end">@Model.TotalTaxableSales.ToString("C")</td>
|
||||
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
|
||||
<td class="text-center">@Model.TaxableInvoiceCount</td>
|
||||
<td class="no-print"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Invoice Detail -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold"><i class="bi bi-receipt me-1"></i>Invoice Detail</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-secondary">@(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</span>
|
||||
<span class="badge bg-light text-muted border">
|
||||
<span class="d-inline-block me-1" style="width:10px;height:10px;background:#f8f9fa;border:1px solid #dee2e6"></span>
|
||||
Non-taxable rows shaded
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th class="text-end">Tax %</th>
|
||||
<th class="text-end">Tax Amount</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end no-print">Paid</th>
|
||||
<th>Tax Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var inv in Model.Invoices)
|
||||
{
|
||||
bool isTaxable = inv.TaxAmount > 0;
|
||||
string statusBadge = inv.Status switch
|
||||
{
|
||||
"Paid" => "bg-success-subtle text-success",
|
||||
"PartiallyPaid" => "bg-warning-subtle text-warning",
|
||||
"Sent" => "bg-info-subtle text-info",
|
||||
"Overdue" => "bg-danger-subtle text-danger",
|
||||
_ => "bg-secondary-subtle text-secondary"
|
||||
};
|
||||
<tr class="@(!isTaxable ? "row-nontaxable" : "")">
|
||||
<td>
|
||||
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@inv.InvoiceId" class="text-decoration-none fw-medium">
|
||||
@inv.InvoiceNumber
|
||||
</a>
|
||||
</td>
|
||||
<td class="small">@inv.CustomerName</td>
|
||||
<td class="small text-muted">@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
|
||||
<td><span class="badge @statusBadge">@inv.Status</span></td>
|
||||
<td class="text-end">@inv.SubTotal.ToString("C")</td>
|
||||
<td class="text-end text-muted small">@(isTaxable ? inv.TaxPercent.ToString("F2") + "%" : "—")</td>
|
||||
<td class="text-end @(isTaxable ? "fw-semibold text-primary" : "text-muted")">@(isTaxable ? inv.TaxAmount.ToString("C") : "—")</td>
|
||||
<td class="text-end fw-semibold">@inv.Total.ToString("C")</td>
|
||||
<td class="text-end text-success no-print">@inv.AmountPaid.ToString("C")</td>
|
||||
<td class="small text-muted">@(string.IsNullOrEmpty(inv.TaxAccountName) ? "—" : inv.TaxAccountName)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td colspan="4">Totals</td>
|
||||
<td class="text-end">@(Model.TotalTaxableSales + Model.TotalNonTaxableSales).ToString("C")</td>
|
||||
<td></td>
|
||||
<td class="text-end text-primary">@Model.TotalTaxBilled.ToString("C")</td>
|
||||
<td class="text-end">@Model.Invoices.Sum(i => i.Total).ToString("C")</td>
|
||||
<td class="text-end text-success no-print">@Model.Invoices.Sum(i => i.AmountPaid).ToString("C")</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2 no-print">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Invoice basis — tax liability is recognized when invoiced, not when collected. Excludes Draft and Voided invoices.
|
||||
</div>
|
||||
|
||||
@if (Model.ByMonth.Count > 1)
|
||||
{
|
||||
@section Scripts {
|
||||
<script src="~/lib/chartjs/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
||||
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)';
|
||||
const textColor = isDark ? '#adb5bd' : '#6c757d';
|
||||
|
||||
new Chart(document.getElementById('taxTrendChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthLabels)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Taxable Sales',
|
||||
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthTaxable)),
|
||||
backgroundColor: 'rgba(79,70,229,0.5)',
|
||||
borderRadius: 4,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'Tax Billed',
|
||||
data: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(monthTaxBilled)),
|
||||
type: 'line',
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
order: 1,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toLocaleString('en-US', {minimumFractionDigits:2}) } }
|
||||
},
|
||||
scales: {
|
||||
y: { ticks: { color: textColor, callback: v => '$' + v.toLocaleString() }, grid: { color: gridColor }, title: { display: true, text: 'Taxable Sales', color: textColor } },
|
||||
y1: { ticks: { color: textColor, callback: v => '$' + v.toLocaleString() }, grid: { display: false }, position: 'right', title: { display: true, text: 'Tax Billed', color: textColor } },
|
||||
x: { ticks: { color: textColor }, grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@
|
||||
<div>
|
||||
<div class="small text-muted">Estimate</div>
|
||||
<div class="fs-5 fw-bold text-success" id="qq-res-price"></div>
|
||||
<div id="qq-res-oven" class="text-muted" style="font-size:0.73rem;"></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="small text-muted">Confidence</div>
|
||||
|
||||
@@ -351,6 +351,17 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.install-app-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-app-btn i {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -627,6 +638,10 @@
|
||||
.stat-card i {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.install-app-btn .install-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
@@ -1434,6 +1449,12 @@
|
||||
</div>
|
||||
|
||||
<div class="user-menu">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm install-app-btn d-none"
|
||||
id="installAppBtn" aria-label="Install app">
|
||||
<i class="bi bi-download"></i>
|
||||
<span class="install-label">Install App</span>
|
||||
</button>
|
||||
|
||||
<!-- Theme toggle -->
|
||||
<button type="button" class="pcl-theme-toggle"
|
||||
data-theme-toggle aria-pressed="false" aria-label="Toggle dark mode">
|
||||
@@ -2140,6 +2161,32 @@
|
||||
</div>
|
||||
<script src="~/js/quick-add.js" asp-append-version="true"></script>
|
||||
<script src="~/js/theme-toggle.js" asp-append-version="true"></script>
|
||||
<script src="~/js/install-app.js" asp-append-version="true"></script>
|
||||
|
||||
<div class="modal fade" id="installHelpModal" tabindex="-1" aria-labelledby="installHelpModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-semibold" id="installHelpModalLabel">Install App</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="fs-3 text-primary">
|
||||
<i class="bi bi-phone"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2" id="installHelpMessage"></p>
|
||||
<div class="small text-muted" id="installHelpSteps"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Got It</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Global Confirm Dialog ──────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="globalConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
resComplexity:document.getElementById('qq-res-complexity'),
|
||||
resMinutes: document.getElementById('qq-res-minutes'),
|
||||
resPrice: document.getElementById('qq-res-price'),
|
||||
resOven: document.getElementById('qq-res-oven'),
|
||||
resConfidence:document.getElementById('qq-res-confidence'),
|
||||
resReasoning: document.getElementById('qq-res-reasoning'),
|
||||
powderSection:document.getElementById('qq-powder-section'),
|
||||
@@ -150,6 +151,15 @@
|
||||
el.resComplexity.textContent = r.complexity || '—';
|
||||
el.resMinutes.textContent = r.estimatedMinutes ? r.estimatedMinutes + ' min' : '—';
|
||||
el.resPrice.textContent = formatCurrency(r.estimatedTotal || r.estimatedUnitPrice);
|
||||
|
||||
const ovenCost = r.breakdown?.ovenCost;
|
||||
const ovenMin = r.breakdown?.ovenCycleMinutes;
|
||||
if (ovenCost && ovenCost > 0) {
|
||||
el.resOven.textContent = `incl. oven 1 batch ${ovenMin ? ovenMin + ' min' : ''}: ${formatCurrency(ovenCost)}`;
|
||||
} else {
|
||||
el.resOven.textContent = '';
|
||||
}
|
||||
|
||||
el.resReasoning.textContent = r.reasoning || '';
|
||||
|
||||
// Confidence badge
|
||||
@@ -220,7 +230,9 @@
|
||||
coatCount: parseInt(el.coats.value, 10) || 1,
|
||||
estimatedUnitPrice: lastResult.estimatedUnitPrice,
|
||||
materialCost: lastResult.breakdown?.materialCost ?? 0,
|
||||
laborCost: lastResult.breakdown?.laborCost ?? 0
|
||||
laborCost: lastResult.breakdown?.laborCost ?? 0,
|
||||
ovenBatchCost: lastResult.breakdown?.ovenCost ?? 0,
|
||||
ovenCycleMinutes: lastResult.breakdown?.ovenCycleMinutes ?? 50
|
||||
};
|
||||
|
||||
const response = await post('/AiQuickQuote/Save', body);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Browser-aware install UX for supported PWAs.
|
||||
// Shows a real install button only when the browser exposes the install prompt,
|
||||
// and falls back to platform-specific instructions for iOS Safari.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const installBtn = document.getElementById('installAppBtn');
|
||||
if (!installBtn) return;
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
const isIos = /iphone|ipad|ipod/i.test(ua);
|
||||
const isIosSafari = isIos && /safari/i.test(ua) && !/crios|fxios|edgios/i.test(ua);
|
||||
|
||||
let deferredPrompt = null;
|
||||
|
||||
function isStandalone() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
function hideInstallButton() {
|
||||
installBtn.classList.add('d-none');
|
||||
installBtn.dataset.installKind = '';
|
||||
}
|
||||
|
||||
function showInstallButton(kind, label, iconClass) {
|
||||
installBtn.dataset.installKind = kind;
|
||||
installBtn.classList.remove('d-none');
|
||||
installBtn.querySelector('.install-label').textContent = label;
|
||||
installBtn.querySelector('i').className = `bi ${iconClass}`;
|
||||
installBtn.setAttribute('aria-label', label);
|
||||
installBtn.title = label;
|
||||
}
|
||||
|
||||
function showHelpModal(title, message, stepsHtml) {
|
||||
const modalEl = document.getElementById('installHelpModal');
|
||||
const modalTitleEl = document.getElementById('installHelpModalLabel');
|
||||
const modalMessageEl = document.getElementById('installHelpMessage');
|
||||
const modalStepsEl = document.getElementById('installHelpSteps');
|
||||
|
||||
if (!modalEl || !modalTitleEl || !modalMessageEl || !modalStepsEl) return;
|
||||
|
||||
modalTitleEl.textContent = title;
|
||||
modalMessageEl.textContent = message;
|
||||
modalStepsEl.innerHTML = stepsHtml;
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
}
|
||||
|
||||
function refreshInstallUi() {
|
||||
if (isStandalone()) {
|
||||
hideInstallButton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deferredPrompt) {
|
||||
showInstallButton('prompt', 'Install App', 'bi-download');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isIosSafari) {
|
||||
showInstallButton('ios', 'Add to Home Screen', 'bi-box-arrow-down');
|
||||
return;
|
||||
}
|
||||
|
||||
hideInstallButton();
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', function (event) {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
refreshInstallUi();
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', function () {
|
||||
deferredPrompt = null;
|
||||
hideInstallButton();
|
||||
});
|
||||
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener?.('change', refreshInstallUi);
|
||||
|
||||
installBtn.addEventListener('click', async function () {
|
||||
const kind = installBtn.dataset.installKind;
|
||||
|
||||
if (kind === 'prompt' && deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
|
||||
try {
|
||||
await deferredPrompt.userChoice;
|
||||
} catch {
|
||||
// Ignore prompt result errors; the browser owns the final install flow.
|
||||
}
|
||||
|
||||
deferredPrompt = null;
|
||||
refreshInstallUi();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'ios') {
|
||||
showHelpModal(
|
||||
'Add to Home Screen',
|
||||
'Safari on iPhone and iPad installs web apps from the Share menu.',
|
||||
'Tap <strong>Share</strong> in Safari, then choose <strong>Add to Home Screen</strong>.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
refreshInstallUi();
|
||||
})();
|
||||
@@ -144,6 +144,7 @@
|
||||
setIfEmpty('field-curetemp', item.cureTemperatureF, 'Cure Temp');
|
||||
setIfEmpty('field-curetime', item.cureTimeMinutes, 'Cure Time');
|
||||
setIfEmpty('field-coverage', item.coverageSqFtPerLb, 'Coverage');
|
||||
setIfEmpty('field-specificgravity', item.specificGravity, 'Specific Gravity');
|
||||
setIfEmpty('field-transfer', item.transferEfficiency,'Transfer Efficiency');
|
||||
|
||||
if (item.requiresClearCoat != null) {
|
||||
@@ -246,6 +247,7 @@
|
||||
|
||||
setIfEmpty('field-finish', data.finish, 'Finish');
|
||||
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
|
||||
setIfEmpty('field-specificgravity', data.specificGravity, 'Specific Gravity');
|
||||
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
|
||||
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
|
||||
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
|
||||
|
||||
@@ -446,6 +446,7 @@
|
||||
setIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp');
|
||||
setIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time');
|
||||
setIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage');
|
||||
setIfEmpty('field-specificgravity', data.specificGravity, 'Specific Gravity');
|
||||
setIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency');
|
||||
|
||||
if (data.unitPrice > 0) {
|
||||
|
||||
@@ -1184,10 +1184,10 @@ async function aiUploadFile(file) {
|
||||
aiRefreshPhotoList();
|
||||
document.getElementById('ai_photoError')?.classList.add('d-none');
|
||||
} else {
|
||||
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||
aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Upload error: ' + err.message);
|
||||
aiShowError('Upload error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,27 +1454,54 @@ function aiShowError(message) {
|
||||
el.textContent = message;
|
||||
el.classList.remove('d-none');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
// Fallback if element not found
|
||||
alert('AI Error: ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Coating layers
|
||||
function renderStep3Html() {
|
||||
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||
return `
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Add one or more coating layers. The first coat uses 100% of the labor estimate;
|
||||
each additional coat adds 30%.
|
||||
</p>
|
||||
<div id="coatsListContainer"></div>
|
||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
|
||||
</button>`;
|
||||
<div class="d-flex align-items-center border rounded py-2 px-3 mb-3 bg-light gap-2">
|
||||
<div class="form-check form-switch mb-0 flex-shrink-0">
|
||||
<input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
|
||||
${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()" style="cursor:pointer">
|
||||
</div>
|
||||
<label for="sandblastOnlyToggle" class="mb-0" style="line-height:1.3; cursor:pointer;">
|
||||
<strong>Sandblast / Prep Only</strong>
|
||||
<span class="d-block text-muted fw-normal small">No powder coating — no oven or powder costs</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="coatingSectionWrap"${isSandblastOnly ? ' class="d-none"' : ''}>
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Add one or more coating layers. The first coat uses 100% of the labor estimate;
|
||||
each additional coat adds 30%.
|
||||
</p>
|
||||
<div id="coatsListContainer"></div>
|
||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
|
||||
</button>
|
||||
</div>
|
||||
${isSandblastOnly ? `<div class="text-center text-muted py-3">
|
||||
<i class="bi bi-tools fs-3 d-block mb-2 opacity-50"></i>
|
||||
No powder coating — no oven or powder costs will be applied.
|
||||
</div>` : ''}`;
|
||||
}
|
||||
|
||||
function onSandblastOnlyToggle() {
|
||||
const checked = document.getElementById('sandblastOnlyToggle')?.checked;
|
||||
wz.data.sandblastOnly = checked;
|
||||
if (checked) {
|
||||
wz.data.coats = [];
|
||||
// Keep manualUnitPrice — if the AI returned a price, preserve it as the sandblast price.
|
||||
// The user can adjust it; clearing it causes a $0 quote when prep services aren't configured.
|
||||
}
|
||||
renderStep(3);
|
||||
}
|
||||
|
||||
function renderCoatsList() {
|
||||
// Sandblast-only items have no coating layers — don't auto-add a coat
|
||||
if (wz.data.sandblastOnly) return;
|
||||
const coats = wz.data.coats || [];
|
||||
if (coats.length === 0) {
|
||||
addCoatRow(); // auto-add a Base Coat
|
||||
@@ -1893,8 +1920,9 @@ function renderStep4Html() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||
const isCatalog = wz.itemType === 'product';
|
||||
const isAi = wz.itemType === 'ai';
|
||||
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
|
||||
const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog
|
||||
const current = wz.data.prepServices || [];
|
||||
|
||||
@@ -1917,6 +1945,12 @@ function renderStep4Html() {
|
||||
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
|
||||
</div>` : '';
|
||||
|
||||
const sandblastBanner = isSandblastOnly ? `
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="bi bi-tools me-1"></i>
|
||||
<strong>Sandblast / Prep Only:</strong> estimated minutes will be billed as labor — no powder or oven costs.
|
||||
</div>` : '';
|
||||
|
||||
const blastOptions = blastSetupData.length > 0
|
||||
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
|
||||
: '';
|
||||
@@ -1960,7 +1994,7 @@ function renderStep4Html() {
|
||||
? ''
|
||||
: `<div class="text-muted small mb-3">Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.</div>`;
|
||||
|
||||
return `${catalogBanner}${aiBanner}${hint}${rows}`;
|
||||
return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`;
|
||||
}
|
||||
|
||||
function onPrepIncludeCostToggle() {
|
||||
@@ -2274,6 +2308,10 @@ function preFillStep2() {
|
||||
|
||||
function buildItemFromWizard() {
|
||||
const d = wz.data;
|
||||
const isSandblastOnly = !!d.sandblastOnly;
|
||||
// AI flag is preserved even for sandblast-only so the server uses the AI price (manualUnitPrice).
|
||||
// Without this, sandblast-only AI items fall through to the pricing engine and return $0 when
|
||||
// no prep services with minutes are configured.
|
||||
const isAi = wz.itemType === 'ai';
|
||||
return {
|
||||
description: d.description || null,
|
||||
@@ -2281,7 +2319,7 @@ function buildItemFromWizard() {
|
||||
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
|
||||
estimatedMinutes: d.estimatedMinutes || 0,
|
||||
catalogItemId: d.catalogItemId || null,
|
||||
manualUnitPrice: d.manualUnitPrice ?? null,
|
||||
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
|
||||
powderCostOverride: d.powderCostOverride ?? null,
|
||||
isGenericItem: !!d.isGenericItem,
|
||||
isLaborItem: !!d.isLaborItem,
|
||||
@@ -2292,12 +2330,13 @@ function buildItemFromWizard() {
|
||||
requiresSandblasting: false,
|
||||
requiresMasking: false,
|
||||
notes: d.notes || null,
|
||||
coats: d.coats || [],
|
||||
coats: isSandblastOnly ? [] : (d.coats || []),
|
||||
prepServices: d.prepServices || [],
|
||||
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
|
||||
complexity: d.complexity || 'Simple',
|
||||
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [],
|
||||
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [],
|
||||
// Keep AI photos even for sandblast-only so they get promoted to permanent storage
|
||||
aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
|
||||
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
|
||||
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
|
||||
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
|
||||
};
|
||||
@@ -2336,8 +2375,11 @@ function buildCardHtml(item, i) {
|
||||
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
|
||||
|
||||
const coatCount = item.coats?.length || 0;
|
||||
const coatBadge = (coatCount > 0)
|
||||
const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId;
|
||||
const coatBadge = coatCount > 0
|
||||
? `<span class="badge bg-secondary ms-1">${coatCount} coat${coatCount > 1 ? 's' : ''}</span>`
|
||||
: isPrepOnly
|
||||
? `<span class="badge bg-warning text-dark ms-1" title="No powder coating"><i class="bi bi-tools me-1"></i>Prep Only</span>`
|
||||
: '';
|
||||
|
||||
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')
|
||||
|
||||
@@ -4,6 +4,7 @@ const jobPhotoModule = {
|
||||
allPhotos: [],
|
||||
currentPhotoIndex: 0,
|
||||
_tagApi: null,
|
||||
_editTagApi: null,
|
||||
|
||||
init: function(jobId, tagSuggestions) {
|
||||
this.jobId = jobId;
|
||||
@@ -21,6 +22,17 @@ const jobPhotoModule = {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reset to view mode when the view modal closes
|
||||
const viewModal = document.getElementById('viewPhotoModal');
|
||||
if (viewModal) {
|
||||
viewModal.addEventListener('hidden.bs.modal', () => {
|
||||
const editPanel = document.getElementById('photoEditPanel');
|
||||
if (editPanel && !editPanel.classList.contains('d-none')) {
|
||||
this.cancelPhotoEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadJobPhotos: function() {
|
||||
@@ -119,6 +131,11 @@ const jobPhotoModule = {
|
||||
},
|
||||
|
||||
navigatePhoto: function(direction) {
|
||||
// Exit edit mode before navigating to another photo
|
||||
const editPanel = document.getElementById('photoEditPanel');
|
||||
if (editPanel && !editPanel.classList.contains('d-none')) {
|
||||
this.cancelPhotoEdit();
|
||||
}
|
||||
this.currentPhotoIndex += direction;
|
||||
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
|
||||
if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0;
|
||||
@@ -212,6 +229,73 @@ const jobPhotoModule = {
|
||||
});
|
||||
},
|
||||
|
||||
editPhoto: function() {
|
||||
const photo = this.allPhotos[this.currentPhotoIndex];
|
||||
document.getElementById('editPhotoType').value = photo.photoType;
|
||||
document.getElementById('editPhotoCaption').value = photo.caption || '';
|
||||
// Pre-populate tag hidden field so initTagInput picks it up
|
||||
document.getElementById('editPhotoTagsHidden').value = photo.tags || '';
|
||||
this._editTagApi = initTagInput('editPhotoTagsHidden', 'editPhotoTagsContainer', {
|
||||
suggestions: this._tagSuggestions
|
||||
});
|
||||
document.getElementById('photoDetails').classList.add('d-none');
|
||||
document.getElementById('photoEditPanel').classList.remove('d-none');
|
||||
document.getElementById('viewModeButtons').classList.add('d-none');
|
||||
document.getElementById('editModeButtons').classList.remove('d-none');
|
||||
},
|
||||
|
||||
cancelPhotoEdit: function() {
|
||||
document.getElementById('photoEditPanel').classList.add('d-none');
|
||||
document.getElementById('photoDetails').classList.remove('d-none');
|
||||
document.getElementById('editModeButtons').classList.add('d-none');
|
||||
document.getElementById('viewModeButtons').classList.remove('d-none');
|
||||
},
|
||||
|
||||
savePhotoEdit: function() {
|
||||
const photo = this.allPhotos[this.currentPhotoIndex];
|
||||
const photoType = parseInt(document.getElementById('editPhotoType').value, 10);
|
||||
const caption = document.getElementById('editPhotoCaption').value.trim();
|
||||
const tags = document.getElementById('editPhotoTagsHidden').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
fetch('/Jobs/UpdatePhoto', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: photo.id,
|
||||
caption: caption,
|
||||
photoType: photoType,
|
||||
tags: tags,
|
||||
displayOrder: photo.displayOrder || 0
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
photo.caption = caption || null;
|
||||
photo.photoType = photoType;
|
||||
photo.photoTypeDisplay = this.getPhotoTypeLabel(photoType);
|
||||
photo.tags = tags || null;
|
||||
photo.tagsList = tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
this.cancelPhotoEdit();
|
||||
this.viewPhoto(this.currentPhotoIndex, true);
|
||||
this.renderPhotoGallery(this.allPhotos);
|
||||
this.showToast('Photo updated successfully', 'success');
|
||||
} else {
|
||||
this.showToast(data.message || 'Error updating photo', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(() => this.showToast('An error occurred while updating', 'danger'));
|
||||
},
|
||||
|
||||
getPhotoTypeLabel: function(type) {
|
||||
const labels = { 0: 'Before', 1: 'Progress', 2: 'After', 3: 'Quality Check', 4: 'Issue', 5: 'Completed' };
|
||||
return labels[type] || 'Unknown';
|
||||
},
|
||||
|
||||
setupDragDrop: function() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('photoFile');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const lookupBtn = document.getElementById('powder-ai-lookup-btn');
|
||||
const urlBtn = document.getElementById('powder-ai-url-btn');
|
||||
const statusEl = document.getElementById('ai-lookup-status');
|
||||
|
||||
if (!lookupBtn || !statusEl) return;
|
||||
|
||||
const endpoints = {
|
||||
lookup: '/PowderCatalog/AiLookup',
|
||||
byUrl: '/PowderCatalog/AiAugmentFromUrl'
|
||||
};
|
||||
|
||||
lookupBtn.addEventListener('click', async function () {
|
||||
const vendorName = getValue('field-vendorname');
|
||||
const colorName = getValue('field-colorname');
|
||||
const sku = getValue('field-sku');
|
||||
|
||||
if (!vendorName && !colorName && !sku) {
|
||||
showStatus('warning', 'Enter a vendor, color name, or SKU first.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runLookup(endpoints.lookup, {
|
||||
vendorName: vendorName,
|
||||
colorName: colorName,
|
||||
sku: sku
|
||||
}, 'Searching the web for missing powder specs and documents...');
|
||||
});
|
||||
|
||||
urlBtn?.addEventListener('click', async function () {
|
||||
const productUrl = getValue('field-producturl');
|
||||
const colorName = getValue('field-colorname');
|
||||
|
||||
if (!productUrl) {
|
||||
showStatus('warning', 'Add a product URL first, then try AI From URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runLookup(endpoints.byUrl, {
|
||||
productUrl: productUrl,
|
||||
colorName: colorName
|
||||
}, 'Reading the product page for missing specs and document links...');
|
||||
});
|
||||
|
||||
async function runLookup(url, payload, loadingMessage) {
|
||||
setButtonsDisabled(true);
|
||||
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>' + loadingMessage);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => formData.append(key, value || ''));
|
||||
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
if (token) formData.append('__RequestVerificationToken', token);
|
||||
|
||||
const response = await fetch(url, { method: 'POST', body: formData });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
showStatus('danger', 'AI lookup failed: ' + (data.errorMessage || 'Unknown error.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const filled = applyLookupResult(data);
|
||||
const reasoning = data.reasoning ? `<div class="text-muted mt-1">${escapeHtml(data.reasoning)}</div>` : '';
|
||||
|
||||
if (filled.length > 0) {
|
||||
showStatus('success', `Filled missing fields: ${filled.join(', ')}.${reasoning}`);
|
||||
} else {
|
||||
showStatus('warning', 'AI found the product, but there were no empty specs or docs to fill.' + reasoning);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('danger', 'AI lookup request failed: ' + error.message);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLookupResult(data) {
|
||||
const filled = [];
|
||||
|
||||
fillIfEmpty('field-vendorname', data.manufacturer || data.vendorName, 'Vendor', filled);
|
||||
fillIfEmpty('field-sku', data.manufacturerPartNumber, 'SKU', filled);
|
||||
fillIfEmpty('field-colorname', data.colorName, 'Color Name', filled);
|
||||
fillIfEmpty('field-description', data.description, 'Description', filled, true);
|
||||
fillIfEmpty('field-finish', data.finish, 'Finish', filled);
|
||||
fillIfEmpty('field-curetemp', data.cureTemperatureF, 'Cure Temp', filled);
|
||||
fillIfEmpty('field-curetime', data.cureTimeMinutes, 'Cure Time', filled);
|
||||
fillIfEmpty('field-coverage', data.coverageSqFtPerLb, 'Coverage', filled);
|
||||
fillIfEmpty('field-specificgravity', data.specificGravity, 'Specific Gravity', filled);
|
||||
fillIfEmpty('field-transfer', data.transferEfficiency, 'Transfer Efficiency', filled);
|
||||
fillIfEmpty('field-producturl', data.specPageUrl, 'Product URL', filled);
|
||||
fillIfEmpty('field-imageurl', data.imageUrl, 'Image URL', filled);
|
||||
fillIfEmpty('field-sdsurl', data.sdsUrl, 'SDS URL', filled);
|
||||
fillIfEmpty('field-tdsurl', data.tdsUrl, 'TDS URL', filled);
|
||||
fillIfEmpty('field-colorfamilies', data.colorFamilies, 'Color Families', filled);
|
||||
|
||||
if (data.unitCostPerLb !== null && data.unitCostPerLb !== undefined) {
|
||||
const unitPrice = document.getElementById('field-unitprice');
|
||||
const current = unitPrice ? parseFloat(unitPrice.value) || 0 : 0;
|
||||
if (unitPrice && current === 0) {
|
||||
unitPrice.value = String(data.unitCostPerLb).trim();
|
||||
filled.push('Unit Price');
|
||||
}
|
||||
}
|
||||
|
||||
const clearCoat = document.getElementById('field-clearcoat');
|
||||
if (clearCoat && isEmptyValue(clearCoat.value) && data.requiresClearCoat !== null && data.requiresClearCoat !== undefined) {
|
||||
clearCoat.value = data.requiresClearCoat ? 'true' : 'false';
|
||||
filled.push('Requires Clear Coat');
|
||||
}
|
||||
|
||||
syncLinkButton('field-producturl', 'field-producturl-link');
|
||||
syncLinkButton('field-sdsurl', 'field-sdsurl-link');
|
||||
syncLinkButton('field-tdsurl', 'field-tdsurl-link');
|
||||
syncLinkButton('field-applicationguideurl', 'field-applicationguideurl-link');
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
function fillIfEmpty(id, value, label, filled, isTextarea) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
const normalized = value !== null && value !== undefined ? String(value).trim() : '';
|
||||
const current = isTextarea ? (el.value || '').trim() : (el.value || '').trim();
|
||||
if (!normalized || current) return;
|
||||
|
||||
el.value = normalized;
|
||||
filled.push(label);
|
||||
}
|
||||
|
||||
function getValue(id) {
|
||||
return document.getElementById(id)?.value?.trim() || '';
|
||||
}
|
||||
|
||||
function syncLinkButton(inputId, linkId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const link = document.getElementById(linkId);
|
||||
if (!input || !link) return;
|
||||
|
||||
if (input.value && input.value.trim()) {
|
||||
link.href = input.value.trim();
|
||||
link.classList.remove('d-none');
|
||||
} else {
|
||||
link.href = '#';
|
||||
link.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonsDisabled(disabled) {
|
||||
lookupBtn.disabled = disabled;
|
||||
if (urlBtn) urlBtn.disabled = disabled;
|
||||
}
|
||||
|
||||
function isEmptyValue(value) {
|
||||
return value === null || value === undefined || String(value).trim() === '';
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
statusEl.className = `alert alert-${type} py-2 small mb-3`;
|
||||
statusEl.innerHTML = message;
|
||||
statusEl.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -2,7 +2,9 @@
|
||||
"name": "Powder Coating Logix",
|
||||
"short_name": "PCLogix",
|
||||
"description": "Powder coating shop management — jobs, quotes, inventory, and scheduling.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1A1A1C",
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
@@ -290,7 +291,8 @@ public class DepositsControllerTests
|
||||
var controller = new DepositsController(
|
||||
uow,
|
||||
userManager.Object,
|
||||
Mock.Of<ILogger<DepositsController>>());
|
||||
Mock.Of<ILogger<DepositsController>>(),
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
|
||||
@@ -253,7 +253,8 @@ public class GiftCertificatesControllerTests
|
||||
Mock.Of<IMapper>(),
|
||||
Mock.Of<ILogger<GiftCertificatesController>>(),
|
||||
userManager.Object,
|
||||
Mock.Of<IPdfService>());
|
||||
Mock.Of<IPdfService>(),
|
||||
Mock.Of<ICompanyLogoService>());
|
||||
|
||||
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
|
||||
|
||||
@@ -417,7 +417,8 @@ public class PricingCalculationServiceTests
|
||||
IsAiItem = true,
|
||||
ManualUnitPrice = 200m,
|
||||
Quantity = 1m,
|
||||
SurfaceAreaSqFt = 50m
|
||||
SurfaceAreaSqFt = 50m,
|
||||
Coats = new List<CreateQuoteItemCoatDto> { new() { CoatName = "Base Coat", Sequence = 1 } }
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -425,7 +426,8 @@ public class PricingCalculationServiceTests
|
||||
IsLaborItem = true,
|
||||
Quantity = 1m,
|
||||
SurfaceAreaSqFt = 50m,
|
||||
EstimatedMinutes = 60
|
||||
EstimatedMinutes = 60,
|
||||
Coats = new List<CreateQuoteItemCoatDto> { new() { CoatName = "Base Coat", Sequence = 1 } }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -470,14 +472,16 @@ public class PricingCalculationServiceTests
|
||||
Description = "AI item",
|
||||
IsAiItem = true,
|
||||
ManualUnitPrice = 100m,
|
||||
Quantity = 1m
|
||||
Quantity = 1m,
|
||||
Coats = new List<CreateQuoteItemCoatDto> { new() { CoatName = "Base Coat", Sequence = 1 } }
|
||||
},
|
||||
new()
|
||||
{
|
||||
Description = "Shelf item",
|
||||
IsSalesItem = true,
|
||||
ManualUnitPrice = 40m,
|
||||
Quantity = 1m
|
||||
Quantity = 1m,
|
||||
Coats = new List<CreateQuoteItemCoatDto> { new() { CoatName = "Base Coat", Sequence = 1 } }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user