Compare commits
4 Commits
7e0699d5bd
...
2e73cfab54
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e73cfab54 | |||
| 74414c6c71 | |||
| a8fb56e8ec | |||
| ca4fb959aa |
@@ -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.
|
||||||
@@ -159,3 +159,65 @@ public class SalesInvoiceLineDto
|
|||||||
public decimal AmountPaid { get; set; }
|
public decimal AmountPaid { get; set; }
|
||||||
public decimal BalanceDue { 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ public interface IFinancialReportService
|
|||||||
|
|
||||||
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
/// <summary>Returns a Sales & Income report for the given company and date range.</summary>
|
||||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public interface IPdfService
|
|||||||
Task<byte[]> GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
|
Task<byte[]> GenerateBalanceSheetPdfAsync(BalanceSheetDto dto);
|
||||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||||
|
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||||
|
|
||||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||||
GiftCertificateDto cert,
|
GiftCertificateDto cert,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Accounting;
|
||||||
using PowderCoating.Application.DTOs.Company;
|
using PowderCoating.Application.DTOs.Company;
|
||||||
using PowderCoating.Application.DTOs.GiftCertificate;
|
using PowderCoating.Application.DTOs.GiftCertificate;
|
||||||
using PowderCoating.Application.DTOs.Invoice;
|
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
|
else
|
||||||
{
|
{
|
||||||
// Non-catalog: derive base from first coat's material + labor + equipment + markup
|
// 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)
|
if (item.Coats != null && item.Coats.Count > 0)
|
||||||
{
|
{
|
||||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
||||||
totalLaborCost = firstCoatResult.CoatLaborCost;
|
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||||
|
totalLaborCost = coatLaborCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prep service labor (done once per item batch)
|
// Prep service labor (done once per item batch)
|
||||||
@@ -443,9 +445,10 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
// Consumables surcharge (5% of material)
|
// Consumables surcharge (5% of material)
|
||||||
totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent;
|
totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent;
|
||||||
|
|
||||||
// Equipment cost: coating booth only (oven cost moved to quote-level batch calculation)
|
// Equipment cost: coating booth only — use coat labor hours, not prep/sandblast hours
|
||||||
var totalLaborHours = totalLaborCost / costs.StandardLaborRate;
|
// (sandblasting happens in a blast cabinet, not the powder coating booth)
|
||||||
totalEquipmentCost = totalLaborHours * costs.CoatingBoothCostPerHour;
|
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
|
// Apply pricing mode: markup on material only, or target margin on total cost
|
||||||
if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost)
|
if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost)
|
||||||
@@ -675,22 +678,24 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
var effectiveBatches = Math.Max(1, ovenBatches);
|
var effectiveBatches = Math.Max(1, ovenBatches);
|
||||||
var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate;
|
var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate;
|
||||||
|
|
||||||
// Scale oven cost by the fraction of total surface area coming from non-AI items.
|
// Only items with coating layers go in the oven — sandblast/prep-only items (zero coats) don't.
|
||||||
// Use item count as a fallback when surface areas are all zero.
|
// Of those coating items, AI items already have oven cost baked into their AI price.
|
||||||
var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
var coatingItems = items.Where(i => i.Coats != null && i.Coats.Any()).ToList();
|
||||||
var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
var nonAiCoatItems = coatingItems.Where(i => !i.IsAiItem).ToList();
|
||||||
var nonAiSqFt = totalSqFt - aiSqFt;
|
|
||||||
|
|
||||||
decimal nonAiFraction;
|
decimal nonAiFraction;
|
||||||
if (totalSqFt > 0)
|
if (!coatingItems.Any())
|
||||||
{
|
{
|
||||||
nonAiFraction = nonAiSqFt / totalSqFt;
|
nonAiFraction = 0m; // No coated items — no oven charge
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var totalCount = items.Count;
|
var totalCoatSqFt = coatingItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||||
var aiCount = items.Count(i => i.IsAiItem);
|
var nonAiCoatSqFt = nonAiCoatItems.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
|
||||||
nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 1m;
|
if (totalCoatSqFt > 0)
|
||||||
|
nonAiFraction = nonAiCoatSqFt / totalCoatSqFt;
|
||||||
|
else
|
||||||
|
nonAiFraction = coatingItems.Count > 0 ? (decimal)nonAiCoatItems.Count / coatingItems.Count : 1m;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ovenBatchCost = fullOvenBatchCost * nonAiFraction;
|
var ovenBatchCost = fullOvenBatchCost * nonAiFraction;
|
||||||
|
|||||||
@@ -254,8 +254,44 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
|||||||
Messages = messages
|
Messages = messages
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
|
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
|
var rawText = response.FirstMessage?.Text
|
||||||
?? response.Content.OfType<TextContent>().FirstOrDefault()?.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."
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error calling Claude AI for quote analysis");
|
_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>
|
/// <summary>
|
||||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
/// 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.
|
/// Falls back to "Your Company" if the record is not found.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
private readonly ICatalogImageService _catalogImageService;
|
private readonly ICatalogImageService _catalogImageService;
|
||||||
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
private readonly IAiCatalogPriceCheckService _priceCheckService;
|
||||||
private readonly IPlatformSettingsService _platformSettings;
|
private readonly IPlatformSettingsService _platformSettings;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public CatalogItemsController(
|
public CatalogItemsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -52,7 +53,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
ISubscriptionService subscriptionService,
|
ISubscriptionService subscriptionService,
|
||||||
ICatalogImageService catalogImageService,
|
ICatalogImageService catalogImageService,
|
||||||
IAiCatalogPriceCheckService priceCheckService,
|
IAiCatalogPriceCheckService priceCheckService,
|
||||||
IPlatformSettingsService platformSettings)
|
IPlatformSettingsService platformSettings,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -65,6 +67,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
_catalogImageService = catalogImageService;
|
_catalogImageService = catalogImageService;
|
||||||
_priceCheckService = priceCheckService;
|
_priceCheckService = priceCheckService;
|
||||||
_platformSettings = platformSettings;
|
_platformSettings = platformSettings;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -906,11 +909,12 @@ namespace PowderCoating.Web.Controllers
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
|
var pdfBytes = await _pdfService.GenerateCatalogPdfAsync(
|
||||||
itemsByCategory,
|
itemsByCategory,
|
||||||
company.CompanyName,
|
company.CompanyName,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType
|
logoContentType
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return PDF file
|
// Return PDF file
|
||||||
@@ -1146,6 +1150,17 @@ namespace PowderCoating.Web.Controllers
|
|||||||
}
|
}
|
||||||
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
|
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
|
// Helper class for hierarchical display
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.DTOs.Company;
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -20,15 +21,18 @@ public class DepositsController : Controller
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<DepositsController> _logger;
|
private readonly ILogger<DepositsController> _logger;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public DepositsController(
|
public DepositsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<DepositsController> logger)
|
ILogger<DepositsController> logger,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -191,7 +195,8 @@ public class DepositsController : Controller
|
|||||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
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\"";
|
Response.Headers["Content-Disposition"] = $"inline; filename=\"Deposit-Receipt-{deposit.ReceiptNumber}.pdf\"";
|
||||||
return File(pdfBytes, "application/pdf");
|
return File(pdfBytes, "application/pdf");
|
||||||
}
|
}
|
||||||
@@ -413,4 +418,15 @@ public class DepositsController : Controller
|
|||||||
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
if (string.IsNullOrWhiteSpace(hex)) return fallback;
|
||||||
return hex.StartsWith("#") ? hex : 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 ILogger<GiftCertificatesController> _logger;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public GiftCertificatesController(
|
public GiftCertificatesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
ILogger<GiftCertificatesController> logger,
|
ILogger<GiftCertificatesController> logger,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPdfService pdfService)
|
IPdfService pdfService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -340,7 +343,8 @@ public class GiftCertificatesController : Controller
|
|||||||
|
|
||||||
try
|
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");
|
return File(pdfBytes, "application/pdf", $"GiftCertificate-{cert.CertificateCode}.pdf");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -390,4 +394,15 @@ public class GiftCertificatesController : Controller
|
|||||||
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
|
list.Insert(0, new SelectListItem { Value = "", Text = "— None (non-customer recipient) —" });
|
||||||
ViewBag.Customers = list;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class InvoicesController : Controller
|
|||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly INotificationService _notificationService;
|
private readonly INotificationService _notificationService;
|
||||||
private readonly IAccountBalanceService _accountBalanceService;
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public InvoicesController(
|
public InvoicesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -36,7 +37,8 @@ public class InvoicesController : Controller
|
|||||||
IPdfService pdfService,
|
IPdfService pdfService,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IAccountBalanceService accountBalanceService)
|
IAccountBalanceService accountBalanceService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -46,6 +48,7 @@ public class InvoicesController : Controller
|
|||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_notificationService = notificationService;
|
_notificationService = notificationService;
|
||||||
_accountBalanceService = accountBalanceService;
|
_accountBalanceService = accountBalanceService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1624,8 +1627,9 @@ public class InvoicesController : Controller
|
|||||||
DefaultTerms = prefs?.InDefaultTerms
|
DefaultTerms = prefs?.InDefaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var dto = await BuildInvoiceDtoAsync(invoice);
|
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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,22 @@ public class PurchaseOrdersController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<PurchaseOrdersController> _logger;
|
private readonly ILogger<PurchaseOrdersController> _logger;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public PurchaseOrdersController(
|
public PurchaseOrdersController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<PurchaseOrdersController> logger,
|
ILogger<PurchaseOrdersController> logger,
|
||||||
IPdfService pdfService)
|
IPdfService pdfService,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -684,8 +687,9 @@ public class PurchaseOrdersController : Controller
|
|||||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
|
var pdfBytes = await _pdfService.GeneratePurchaseOrderPdfAsync(
|
||||||
dto, company?.LogoData, company?.LogoContentType, companyInfo);
|
dto, logoData, logoContentType, companyInfo);
|
||||||
|
|
||||||
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
|
return File(pdfBytes, "application/pdf", $"{po.PoNumber}.pdf");
|
||||||
}
|
}
|
||||||
@@ -847,4 +851,15 @@ public class PurchaseOrdersController : Controller
|
|||||||
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
vendors.Insert(0, new SelectListItem("All Vendors", ""));
|
||||||
ViewBag.VendorList = 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 IWebHostEnvironment _env;
|
||||||
private readonly IJobPhotoService _jobPhotoService;
|
private readonly IJobPhotoService _jobPhotoService;
|
||||||
private readonly IAiUsageLogger _usageLogger;
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
|
||||||
public QuotesController(
|
public QuotesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -59,7 +60,8 @@ public class QuotesController : Controller
|
|||||||
IAiQuoteService aiService,
|
IAiQuoteService aiService,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env,
|
||||||
IJobPhotoService jobPhotoService,
|
IJobPhotoService jobPhotoService,
|
||||||
IAiUsageLogger usageLogger)
|
IAiUsageLogger usageLogger,
|
||||||
|
ICompanyLogoService logoService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -79,6 +81,7 @@ public class QuotesController : Controller
|
|||||||
_env = env;
|
_env = env;
|
||||||
_jobPhotoService = jobPhotoService;
|
_jobPhotoService = jobPhotoService;
|
||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
|
_logoService = logoService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -604,10 +607,11 @@ public class QuotesController : Controller
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
|
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
|
||||||
quoteDto,
|
quoteDto,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType,
|
logoContentType,
|
||||||
companyInfo,
|
companyInfo,
|
||||||
template: template
|
template: template
|
||||||
);
|
);
|
||||||
@@ -1037,13 +1041,22 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Promote AI temp photos to permanent storage and create QuotePhoto records
|
// 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)
|
if (dto.AiPhotoTempIds?.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
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 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)
|
if (promoted)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||||
@@ -1895,13 +1908,22 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Promote any new AI temp photos and create QuotePhoto records
|
// 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)
|
if (dto.AiPhotoTempIds?.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
|
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 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)
|
if (promoted)
|
||||||
{
|
{
|
||||||
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
|
||||||
@@ -2826,10 +2848,11 @@ public class QuotesController : Controller
|
|||||||
DefaultTerms = prefs?.QtDefaultTerms
|
DefaultTerms = prefs?.QtDefaultTerms
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
return await _pdfService.GenerateQuotePdfAsync(
|
return await _pdfService.GenerateQuotePdfAsync(
|
||||||
quoteDto,
|
quoteDto,
|
||||||
company.LogoData,
|
logoData,
|
||||||
company.LogoContentType,
|
logoContentType,
|
||||||
companyInfo,
|
companyInfo,
|
||||||
template: template);
|
template: template);
|
||||||
}
|
}
|
||||||
@@ -3908,6 +3931,30 @@ public class QuotesController : Controller
|
|||||||
return Json(new { success = true });
|
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)
|
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
|
||||||
{
|
{
|
||||||
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
@@ -3936,6 +3983,21 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_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
|
// 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");
|
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 ──────────────────────────────────────────────
|
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -569,6 +569,7 @@ public static class HelpKnowledgeBase
|
|||||||
- *Balance Sheet* — assets, liabilities, equity snapshot
|
- *Balance Sheet* — assets, liabilities, equity snapshot
|
||||||
- *AR Aging* — outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
|
- *AR Aging* — outstanding invoices grouped by age (0-30, 31-60, 61-90, 90+ days)
|
||||||
- *Sales & Income* — revenue trends by period
|
- *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
|
- *Revenue Trends* — monthly/quarterly revenue charting
|
||||||
- *Operations Report* — job throughput, cycle times, status breakdown
|
- *Operations Report* — job throughput, cycle times, status breakdown
|
||||||
- *Customer Overview* — top customers, revenue per customer
|
- *Customer Overview* — top customers, revenue per customer
|
||||||
@@ -579,7 +580,7 @@ public static class HelpKnowledgeBase
|
|||||||
- *Powder Usage Report* — powder consumption by item/job
|
- *Powder Usage Report* — powder consumption by item/job
|
||||||
- *Job Cycle Time Report* — how long jobs spend in each status
|
- *Job Cycle Time Report* — how long jobs spend in each status
|
||||||
|
|
||||||
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):
|
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.*
|
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.*
|
||||||
|
|
||||||
|
|||||||
@@ -479,7 +479,7 @@
|
|||||||
<i class="bi bi-bag-plus-fill me-2 text-muted"></i>Powder in Queue to be Ordered
|
<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>
|
<span class="ms-2 text-muted fw-normal small">@Model.PowderOrdersNeededCount item@(Model.PowderOrdersNeededCount == 1 ? "" : "s")</span>
|
||||||
</h5>
|
</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>
|
||||||
<div class="card-body pt-0 pb-3">
|
<div class="card-body pt-0 pb-3">
|
||||||
@foreach (var vendorGroup in Model.PowderOrdersNeeded)
|
@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
|
<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>
|
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
|
||||||
</h5>
|
</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>
|
||||||
<div class="card-body pt-0 pb-3" id="placed-card-body">
|
<div class="card-body pt-0 pb-3" id="placed-card-body">
|
||||||
@if (Model.PowderOrdersPlaced.Any())
|
@if (Model.PowderOrdersPlaced.Any())
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
@if (Model.ExpiryDate.HasValue)
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,6 +96,35 @@
|
|||||||
patterns in your job volume and revenue.
|
patterns in your job volume and revenue.
|
||||||
</p>
|
</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>
|
<h3 class="h6 fw-semibold mt-3 mb-2">Revenue Trends</h3>
|
||||||
<p>
|
<p>
|
||||||
A charting view of monthly and quarterly revenue over time. Useful for year-over-year
|
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">
|
<section id="pdf-export" class="mb-5">
|
||||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
<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>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Most financial reports (P&L, Balance Sheet, AR Aging, and others) include a
|
Most financial reports (P&L, Balance Sheet, AR Aging, Sales & Income, and others)
|
||||||
<strong>Download PDF</strong> button. Use this to generate a print-ready version for your
|
include a <strong>Download PDF</strong> button. Use this to generate a print-ready version
|
||||||
accountant, a business review, or your own records.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -261,9 +301,10 @@
|
|||||||
<nav class="nav flex-column">
|
<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="#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" 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="#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="#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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -366,7 +366,7 @@
|
|||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||||
{
|
{
|
||||||
<text> · @coat.Finish</text>
|
<text> · @coat.Finish</text>
|
||||||
}
|
}
|
||||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
{
|
{
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
}
|
}
|
||||||
@if (!string.IsNullOrEmpty(coat.Finish))
|
@if (!string.IsNullOrEmpty(coat.Finish))
|
||||||
{
|
{
|
||||||
<text> · @coat.Finish</text>
|
<text> · @coat.Finish</text>
|
||||||
}
|
}
|
||||||
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
@if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
{
|
{
|
||||||
@@ -1680,13 +1680,47 @@
|
|||||||
<p class="mb-1"><strong>Uploaded:</strong> <span id="photoDetailDate"></span></p>
|
<p class="mb-1"><strong>Uploaded:</strong> <span id="photoDetailDate"></span></p>
|
||||||
<p class="mb-0"><strong>By:</strong> <span id="photoDetailUploader"></span></p>
|
<p class="mb-0"><strong>By:</strong> <span id="photoDetailUploader"></span></p>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<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()">
|
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.deletePhoto()">
|
||||||
<i class="bi bi-trash me-1"></i>Delete
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1747,7 +1781,7 @@
|
|||||||
<small>@item.Description</small>
|
<small>@item.Description</small>
|
||||||
@if (item.Quantity > 1)
|
@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>
|
||||||
<td>
|
<td>
|
||||||
@@ -1940,7 +1974,7 @@
|
|||||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
<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>
|
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||||
</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>
|
||||||
<div id="cylinderInputs" style="display:none">
|
<div id="cylinderInputs" style="display:none">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
@@ -2666,7 +2700,7 @@
|
|||||||
pBody.innerHTML = d.hasPowderData
|
pBody.innerHTML = d.hasPowderData
|
||||||
? d.powderLines.map(l => `<tr>
|
? 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-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('')
|
<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>';
|
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
|
||||||
|
|
||||||
@@ -2675,7 +2709,7 @@
|
|||||||
lBody.innerHTML = d.hasLaborData
|
lBody.innerHTML = d.hasLaborData
|
||||||
? d.laborLines.map(l => `<tr>
|
? d.laborLines.map(l => `<tr>
|
||||||
<td class="text-muted">${l.worker}${l.stage ? ' — ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
|
<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('')
|
<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>';
|
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
@foreach (var item in job.Items)
|
@foreach (var item in job.Items)
|
||||||
{
|
{
|
||||||
<li class="d-flex align-items-start gap-2 mb-1">
|
<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>
|
<span class="small">@item.Description</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,7 +398,7 @@
|
|||||||
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary btn-lg">
|
<a asp-action="Index" asp-controller="Quotes" class="btn btn-outline-secondary btn-lg">
|
||||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
</a>
|
</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
|
<i class="bi bi-check-circle me-1"></i>Create Quote
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -764,13 +764,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-2 small text-muted" id="qpDate"></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-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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
<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()">
|
<button type="button" class="btn btn-danger" id="qpDeleteBtn" onclick="qpGallery.deletePhoto()">
|
||||||
<i class="bi bi-trash me-1"></i>Delete
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -782,13 +801,15 @@
|
|||||||
url = Url.Action("Photo", "Quotes", new { id = p.Id }),
|
url = Url.Action("Photo", "Quotes", new { id = p.Id }),
|
||||||
fileName = p.FileName,
|
fileName = p.FileName,
|
||||||
date = p.CreatedAt.ToString("MMM d, yyyy"),
|
date = p.CreatedAt.ToString("MMM d, yyyy"),
|
||||||
isAi = p.IsAiAnalysisPhoto
|
isAi = p.IsAiAnalysisPhoto,
|
||||||
|
caption = p.Caption
|
||||||
})));
|
})));
|
||||||
|
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
const quoteId = @Model.Id;
|
const quoteId = @Model.Id;
|
||||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||||
|
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
|
||||||
const token = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -799,8 +820,14 @@
|
|||||||
document.getElementById('qpFileName').textContent = p.fileName;
|
document.getElementById('qpFileName').textContent = p.fileName;
|
||||||
document.getElementById('qpPosition').textContent = `Photo ${currentIndex + 1} of ${photos.length}`;
|
document.getElementById('qpPosition').textContent = `Photo ${currentIndex + 1} of ${photos.length}`;
|
||||||
document.getElementById('qpDeleteBtn').style.display = p.isAi ? 'none' : '';
|
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('qpPrev').disabled = photos.length <= 1;
|
||||||
document.getElementById('qpNext').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) {
|
function open(index) {
|
||||||
@@ -810,10 +837,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigate(dir) {
|
function navigate(dir) {
|
||||||
|
const editPanel = document.getElementById('qpEditPanel');
|
||||||
|
if (editPanel && !editPanel.classList.contains('d-none')) cancelEdit();
|
||||||
currentIndex = (currentIndex + dir + photos.length) % photos.length;
|
currentIndex = (currentIndex + dir + photos.length) % photos.length;
|
||||||
render();
|
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() {
|
async function deletePhoto() {
|
||||||
if (!confirm('Delete this photo?')) return;
|
if (!confirm('Delete this photo?')) return;
|
||||||
const p = photos[currentIndex];
|
const p = photos[currentIndex];
|
||||||
@@ -864,7 +923,7 @@
|
|||||||
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
|
if (!data.success) { alert(data.error || 'Upload failed.'); return; }
|
||||||
|
|
||||||
const newIndex = photos.length;
|
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();
|
document.getElementById('noPhotosMsg')?.remove();
|
||||||
const grid = document.getElementById('photoGrid');
|
const grid = document.getElementById('photoGrid');
|
||||||
@@ -884,7 +943,13 @@
|
|||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
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>
|
</script>
|
||||||
}
|
}
|
||||||
@@ -920,7 +985,7 @@
|
|||||||
<span>
|
<span>
|
||||||
<i class="bi bi-fire me-1"></i>Oven
|
<i class="bi bi-fire me-1"></i>Oven
|
||||||
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")
|
(@Model.PricingBreakdown.OvenBatches batch@(Model.PricingBreakdown.OvenBatches != 1 ? "es" : "")
|
||||||
× @Model.PricingBreakdown.OvenCycleMinutes min):
|
× @Model.PricingBreakdown.OvenCycleMinutes min):
|
||||||
</span>
|
</span>
|
||||||
<strong>@Model.PricingBreakdown.OvenBatchCost.ToString("C")</strong>
|
<strong>@Model.PricingBreakdown.OvenBatchCost.ToString("C")</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -1225,7 +1290,7 @@
|
|||||||
{
|
{
|
||||||
<span class="text-muted ms-2" style="font-size:.8rem;">
|
<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) { <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> }
|
@if (item.EstimatedMinutes > 0) { <text>@item.EstimatedMinutes min</text> }
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -1233,7 +1298,7 @@
|
|||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
@if (item.Quantity > 1)
|
@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>
|
<span class="fw-semibold">@item.TotalPrice.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,6 +363,10 @@
|
|||||||
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
style="z-index:1;font-size:.7rem;" data-photo-id="@photo.Id" title="Delete">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</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">
|
<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 })"
|
<img src="@Url.Action("Photo", "Quotes", new { id = photo.Id })"
|
||||||
@@ -372,6 +376,17 @@
|
|||||||
<div class="card-body p-2">
|
<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 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>
|
<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>
|
</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">
|
<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
|
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||||
</a>
|
</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
|
<i class="bi bi-check-circle me-1"></i>Update Quote
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,6 +723,7 @@
|
|||||||
const quoteId = @Model.Id;
|
const quoteId = @Model.Id;
|
||||||
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
|
||||||
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
const deleteUrl = '@Url.Action("DeleteQuotePhoto", "Quotes")';
|
||||||
|
const updateUrl = '@Url.Action("UpdateQuotePhoto", "Quotes")';
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||||
|
|
||||||
const fileInput = document.getElementById('editPhotoFileInput');
|
const fileInput = document.getElementById('editPhotoFileInput');
|
||||||
@@ -744,12 +760,24 @@
|
|||||||
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
style="z-index:1;font-size:.7rem;" data-photo-id="${data.id}" title="Delete">
|
||||||
<i class="bi bi-x"></i>
|
<i class="bi bi-x"></i>
|
||||||
</button>
|
</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">
|
<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">
|
<img src="${data.url}" class="card-img-top" style="height:160px;object-fit:cover;" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
<p class="card-text small text-muted text-truncate mb-0">${data.fileName}</p>
|
<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 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>
|
||||||
</div>`;
|
</div>`;
|
||||||
grid.appendChild(col);
|
grid.appendChild(col);
|
||||||
@@ -771,6 +799,45 @@
|
|||||||
updateCount(-1);
|
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) {
|
function updateCount(delta) {
|
||||||
const badge = document.getElementById('photoCount');
|
const badge = document.getElementById('photoCount');
|
||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + delta;
|
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>
|
<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>
|
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||||
</a>
|
</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">
|
<a asp-controller="Reports" asp-action="CustomerOverview" class="report-card">
|
||||||
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
|
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
|
||||||
<i class="bi bi-people"></i>
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1184,10 +1184,10 @@ async function aiUploadFile(file) {
|
|||||||
aiRefreshPhotoList();
|
aiRefreshPhotoList();
|
||||||
document.getElementById('ai_photoError')?.classList.add('d-none');
|
document.getElementById('ai_photoError')?.classList.add('d-none');
|
||||||
} else {
|
} else {
|
||||||
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
aiShowError('Upload failed: ' + (result.error || 'Unknown error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Upload error: ' + err.message);
|
aiShowError('Upload error: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,24 +1454,49 @@ function aiShowError(message) {
|
|||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
el.classList.remove('d-none');
|
el.classList.remove('d-none');
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
} else {
|
|
||||||
// Fallback if element not found
|
|
||||||
alert('AI Error: ' + message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Coating layers
|
// Step 3: Coating layers
|
||||||
function renderStep3Html() {
|
function renderStep3Html() {
|
||||||
|
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||||
return `
|
return `
|
||||||
|
<div class="form-check form-switch border rounded py-2 px-3 mb-3 bg-light">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sandblastOnlyToggle"
|
||||||
|
${isSandblastOnly ? 'checked' : ''} onchange="onSandblastOnlyToggle()">
|
||||||
|
<label class="form-check-label" for="sandblastOnlyToggle">
|
||||||
|
<strong>Sandblast / Prep Only</strong>
|
||||||
|
<span class="text-muted fw-normal ms-2 small">— no powder coating applied</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="coatingSectionWrap"${isSandblastOnly ? ' class="d-none"' : ''}>
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
Add one or more coating layers. The first coat uses 100% of the labor estimate;
|
Add one or more coating layers. The first coat uses 100% of the labor estimate;
|
||||||
each additional coat adds 30%.
|
each additional coat adds 30%.
|
||||||
</p>
|
</p>
|
||||||
<div id="coatsListContainer"></div>
|
<div id="coatsListContainer"></div>
|
||||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addCoatRow()">
|
<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
|
<i class="bi bi-plus-circle me-1"></i>Add Coating Layer
|
||||||
</button>`;
|
</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 = [];
|
||||||
|
// AI price was estimated with coating in mind — clear it so pricing recalculates from prep labor
|
||||||
|
if (wz.itemType === 'ai') {
|
||||||
|
wz.data.manualUnitPrice = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderStep(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCoatsList() {
|
function renderCoatsList() {
|
||||||
@@ -1893,8 +1918,9 @@ function renderStep4Html() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSandblastOnly = !!wz.data.sandblastOnly;
|
||||||
const isCatalog = wz.itemType === 'product';
|
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 includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog
|
||||||
const current = wz.data.prepServices || [];
|
const current = wz.data.prepServices || [];
|
||||||
|
|
||||||
@@ -1917,6 +1943,12 @@ function renderStep4Html() {
|
|||||||
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
|
Select the services below for shop floor reference — they will <strong>not</strong> add to the item price.
|
||||||
</div>` : '';
|
</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
|
const blastOptions = blastSetupData.length > 0
|
||||||
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
|
? blastSetupData.map(s => `<option value="${s.id}" ${s.isDefault ? 'selected' : ''}>${escHtml(s.name)}</option>`).join('')
|
||||||
: '';
|
: '';
|
||||||
@@ -1960,7 +1992,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>`;
|
: `<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() {
|
function onPrepIncludeCostToggle() {
|
||||||
@@ -2274,14 +2306,17 @@ function preFillStep2() {
|
|||||||
|
|
||||||
function buildItemFromWizard() {
|
function buildItemFromWizard() {
|
||||||
const d = wz.data;
|
const d = wz.data;
|
||||||
const isAi = wz.itemType === 'ai';
|
const isSandblastOnly = !!d.sandblastOnly;
|
||||||
|
// Sandblast-only AI items lose the AI pricing flag — the AI price included coating costs
|
||||||
|
// that no longer apply, so the server prices from prep labor instead.
|
||||||
|
const isAi = wz.itemType === 'ai' && !isSandblastOnly;
|
||||||
return {
|
return {
|
||||||
description: d.description || null,
|
description: d.description || null,
|
||||||
quantity: d.quantity || 1,
|
quantity: d.quantity || 1,
|
||||||
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
|
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
|
||||||
estimatedMinutes: d.estimatedMinutes || 0,
|
estimatedMinutes: d.estimatedMinutes || 0,
|
||||||
catalogItemId: d.catalogItemId || null,
|
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,
|
powderCostOverride: d.powderCostOverride ?? null,
|
||||||
isGenericItem: !!d.isGenericItem,
|
isGenericItem: !!d.isGenericItem,
|
||||||
isLaborItem: !!d.isLaborItem,
|
isLaborItem: !!d.isLaborItem,
|
||||||
@@ -2296,8 +2331,9 @@ function buildItemFromWizard() {
|
|||||||
prepServices: d.prepServices || [],
|
prepServices: d.prepServices || [],
|
||||||
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
|
includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'),
|
||||||
complexity: d.complexity || 'Simple',
|
complexity: d.complexity || 'Simple',
|
||||||
aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [],
|
// Keep AI photos even for sandblast-only so they get promoted to permanent storage
|
||||||
aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [],
|
aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [],
|
||||||
|
aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [],
|
||||||
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
|
aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null,
|
||||||
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
|
aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null
|
||||||
};
|
};
|
||||||
@@ -2336,8 +2372,11 @@ function buildCardHtml(item, i) {
|
|||||||
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
|
: { label: 'Custom', cls: 'success', icon: 'bi-rulers' };
|
||||||
|
|
||||||
const coatCount = item.coats?.length || 0;
|
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>`
|
? `<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')
|
const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const jobPhotoModule = {
|
|||||||
allPhotos: [],
|
allPhotos: [],
|
||||||
currentPhotoIndex: 0,
|
currentPhotoIndex: 0,
|
||||||
_tagApi: null,
|
_tagApi: null,
|
||||||
|
_editTagApi: null,
|
||||||
|
|
||||||
init: function(jobId, tagSuggestions) {
|
init: function(jobId, tagSuggestions) {
|
||||||
this.jobId = jobId;
|
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() {
|
loadJobPhotos: function() {
|
||||||
@@ -119,6 +131,11 @@ const jobPhotoModule = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
navigatePhoto: function(direction) {
|
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;
|
this.currentPhotoIndex += direction;
|
||||||
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
|
if (this.currentPhotoIndex < 0) this.currentPhotoIndex = this.allPhotos.length - 1;
|
||||||
if (this.currentPhotoIndex >= this.allPhotos.length) this.currentPhotoIndex = 0;
|
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() {
|
setupDragDrop: function() {
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
const fileInput = document.getElementById('photoFile');
|
const fileInput = document.getElementById('photoFile');
|
||||||
|
|||||||
Reference in New Issue
Block a user