Compare commits

...

6 Commits

Author SHA1 Message Date
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
27 changed files with 22644 additions and 39 deletions
@@ -57,6 +57,7 @@ public class InvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? ExternalReference { get; set; }
public int? SalesTaxAccountId { get; set; }
public string? SalesTaxAccountName { get; set; }
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
}
@@ -52,6 +52,7 @@ public class JobDto
public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; }
public string? Tags { get; set; }
@@ -114,6 +115,7 @@ public class JobListDto
public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; }
@@ -167,6 +169,7 @@ public class CreateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")]
@@ -252,6 +255,7 @@ public class UpdateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")]
@@ -107,6 +107,7 @@ public class QuoteDto
public string? Terms { get; set; }
public string? Notes { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; }
// Items
@@ -234,6 +235,7 @@ public class CreateQuoteDto
[Display(Name = "Customer PO Number")]
[StringLength(50)]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")]
[StringLength(500)]
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
[Display(Name = "Customer PO Number")]
[StringLength(50)]
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")]
[StringLength(500)]
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
CreateMap<Invoice, InvoiceDto>()
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
? (s.Customer.IsCommercial
? s.Customer.CompanyName
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
c.Item().Text($"Job #: {invoice.JobNumber}");
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
c.Item().Text($"PO #: {invoice.CustomerPO}");
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
c.Item().Text($"Project: {invoice.ProjectName}");
});
});
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
});
}
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
{
column.Item().Row(row =>
{
row.ConstantItem(80).Text("Project:").FontSize(9);
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
});
}
});
}
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>
/// Early payment discount percentage (e.g., 2 means 2% discount).
+1
View File
@@ -47,6 +47,7 @@ public class Job : BaseEntity
// Additional Information
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } // Internal notes from quote
public string? Tags { get; set; }
+1
View File
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
public string? Terms { get; set; }
public string? Notes { get; set; }
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; }
// Conversion tracking
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddProjectNameToQuotesAndJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Quotes",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Quotes");
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInvoiceProjectName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectName",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
}
}
}
@@ -4269,6 +4269,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)");
@@ -4560,6 +4563,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId")
.HasColumnType("int");
@@ -7053,7 +7059,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7064,7 +7070,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7075,7 +7081,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7385,6 +7391,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ProfitPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProspectAddress")
.HasColumnType("nvarchar(max)");
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteApproved, values,
$"Quote {quote.QuoteNumber} Approved — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, notifType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
job.CompanyId, NotificationType.JobCompleted, values,
$"Job {job.JobNumber} Complete — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
""";
}
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = !string.IsNullOrEmpty(paymentUrl)
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml);
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReceived, values,
$"Payment Received — Invoice {invoice.InvoiceNumber}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReminder, values,
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, notificationType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync(
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
/// <summary>
/// Appends CAN-SPAM required footer as HTML.
/// </summary>
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
{
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
if (!hasUnsubscribeUrl && !hasAddress)
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
return htmlBody;
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
if (hasReplyTo)
{
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
}
if (hasAddress)
{
var addressLine = BuildAddressLine(company!);
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
var email = prefs?.EmailFromAddress;
var name = prefs?.EmailFromName;
if (string.IsNullOrWhiteSpace(email))
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
else
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
return (email, name);
}
/// <summary>
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
dto.JobId = job.Id;
dto.CustomerId = job.CustomerId;
dto.CustomerPO = job.CustomerPO;
dto.ProjectName = job.ProjectName;
// Resolve catalog item revenue accounts for pre-population
var catalogItemIds = job.JobItems
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
InternalNotes = dto.InternalNotes,
Terms = dto.Terms,
CustomerPO = dto.CustomerPO,
ProjectName = dto.ProjectName,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
InternalNotes = invoice.InternalNotes,
Terms = invoice.Terms,
CustomerPO = invoice.CustomerPO,
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
InvoiceItems = invoice.InvoiceItems
.Where(i => !i.IsDeleted)
.OrderBy(i => i.DisplayOrder)
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
invoice.InternalNotes = dto.InternalNotes;
invoice.Terms = dto.Terms;
invoice.CustomerPO = dto.CustomerPO;
invoice.ProjectName = dto.ProjectName;
invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email;
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached)
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
// Update quote to link to new customer
// Update quote to link to new customer.
// Do NOT set "Converted" status here — that status is reserved for when a job is
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
// user immediately click "Create Job from Quote" on the next screen.
quote.CustomerId = customer.Id;
// Clear prospect fields
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
return RedirectToAction("Details", "Customers", new { id = customer.Id });
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
}
catch (Exception ex)
{
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
Total = quote.Total
}),
CustomerPO = quote.CustomerPO,
ProjectName = quote.ProjectName,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
IsRushJob = quote.IsRushJob,
@@ -44,11 +44,21 @@
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
title="Click to filter list to low stock items">
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
style="cursor:pointer;transition:box-shadow .15s;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
<p class="text-muted mb-1" style="font-size: 0.875rem;">
Low Stock Items
@if (lowStockCount > 0)
{
<i class="bi bi-funnel-fill ms-1 text-danger" style="font-size:.7rem;" title="Click to filter"></i>
}
</p>
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
</div>
<div class="rounded-circle p-3" style="background: #fee2e2;">
@@ -57,6 +67,7 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
@@ -102,11 +113,13 @@
<div class="stat-value">@Model.TotalCount</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-item">
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
<div class="stat-item" style="cursor:pointer;">
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
<div class="stat-label">Low Stock</div>
</div>
</a>
<div class="stat-item">
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
<div class="stat-value">@activeCount</div>
@@ -170,6 +170,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<div class="d-flex align-items-center gap-1">
@@ -193,6 +193,13 @@
<p class="mb-0">@Model.CustomerPO</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project Name</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
{
<div class="col-md-6">
@@ -62,6 +62,12 @@
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="Optional &mdash; prints on invoice" />
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-12">
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
@@ -124,6 +124,10 @@
</div>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7">
<div class="d-flex align-items-center gap-1">
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
@@ -172,6 +172,13 @@
<label class="text-muted small mb-1">Customer PO</label>
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
</div>
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<div class="col-md-6">
<label class="text-muted small mb-1">Project</label>
<p class="mb-0">@Model.ProjectName</p>
</div>
}
<div class="col-12">
<label class="text-muted small mb-1">Description</label>
<p class="mb-0">@Model.Description</p>
@@ -101,6 +101,10 @@
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
</div>
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label">Project Name</label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
<div class="col-md-7">
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
@@ -357,6 +357,13 @@
<div class="info-value">@Model.CustomerPO</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
{
<div class="info-row">
<div class="info-label">Project</div>
<div class="info-value">@Model.ProjectName</div>
</div>
}
</div>
<div class="col-6">
<div class="section-title">
@@ -187,6 +187,12 @@
<input asp-for="CustomerPO" class="form-control" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Notes" class="form-label"></label>
@@ -183,6 +183,10 @@
{
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
}
@if (!string.IsNullOrEmpty(Model.ProjectName))
{
<p><strong>Project:</strong> @Model.ProjectName</p>
}
</div>
</div>
@if (!string.IsNullOrEmpty(Model.Description))
@@ -150,6 +150,12 @@
<input asp-for="CustomerPO" class="form-control" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="ProjectName" class="form-label"></label>
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3&hellip;" />
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<label asp-for="Notes" class="form-label"></label>