Compare commits

...

7 Commits

Author SHA1 Message Date
spouliot 7ad7d84016 Add mobile card views to Invoices and Intakes list pages
Both pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px but neither page had a .mobile-card-view section. Added card-per-row
mobile layout to match the Customers page pattern — tappable cards with status
badges, key fields, and action buttons sized for touch.

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

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

Migration: AddGiftCertificateBatchId

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
spouliot cefdf3e35c Add remaining-weight input mode to inventory scan/usage page
Users can now toggle between 'Amount Used' and 'Remaining Weight' on the
QR scan page. In remaining-weight mode, usage is calculated as
(current stock - remaining) before submit — no controller changes needed.
Includes live hint showing calculated usage and new balance as they type,
with validation preventing negative usage or remaining > current stock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:12:28 -04:00
spouliot f34ee749be Fix garbled encoding symbols in oven display, bill/invoice tooltips, and profile timezone dropdown
- Replace mojibake × with × in oven batch cost row across Jobs Create/Edit/EditItems and Quotes Create/Edit
- Fix Qty × Unit Price tooltip in Bills/Edit and Invoices/Edit
- Fix all â€" (garbled em dash) and São Paulo in Profile timezone dropdown option labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:54:35 -04:00
26 changed files with 11703 additions and 113 deletions
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; } public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; } public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; } public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
} }
public class GiftCertificateDto : GiftCertificateListDto public class GiftCertificateDto : GiftCertificateListDto
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)] [Range(0.01, 9999.99)]
public decimal Amount { get; set; } public decimal Amount { get; set; }
} }
public class BulkCreateGiftCertificateDto
{
[Required]
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
[Display(Name = "Number of Certificates")]
public int Quantity { get; set; } = 25;
[Required]
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
[Display(Name = "Face Value (each)")]
public decimal Amount { get; set; }
[Required]
[Display(Name = "Issued Reason")]
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
[Display(Name = "Expiry Date (optional)")]
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)]
[Display(Name = "Event / Notes (applied to all certificates)")]
public string? Notes { get; set; }
}
@@ -51,4 +51,10 @@ public interface IPdfService
byte[]? companyLogo, byte[]? companyLogo,
string? companyLogoContentType, string? companyLogoContentType,
CompanyInfoDto companyInfo); CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
} }
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
}); });
} }
/// <summary>
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
/// <summary> /// <summary>
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt, /// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the /// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary> /// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; } public int? SourceInvoiceItemId { get; set; }
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
public Guid? BatchId { get; set; }
// Navigation // Navigation
public virtual Customer? RecipientCustomer { get; set; } public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; } public virtual Customer? PurchasingCustomer { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGiftCertificateBatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BatchId",
table: "GiftCertificates",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchId",
table: "GiftCertificates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
}
}
@@ -3290,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode") b.Property<string>("CertificateCode")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -6708,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6719,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6730,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482), CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
IssuedReason = gc.IssuedReason, IssuedReason = gc.IssuedReason,
Status = gc.Status, Status = gc.Status,
IssueDate = gc.IssueDate, IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate ExpiryDate = gc.ExpiryDate,
BatchId = gc.BatchId
}) })
.ToList(); .ToList();
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
return acct?.Id; return acct?.Id;
} }
/// <summary>
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
/// since the primary use case is car shows and events where a batch of same-value certificates
/// is distributed to attendees.
/// </summary>
public IActionResult BulkCreate()
{
return View(new BulkCreateGiftCertificateDto());
}
/// <summary>
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
/// Sales Discounts (4950) and credit GC Liability (2500).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
int? checkingAcctId = null;
int? discountAcctId = null;
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id;
}
else
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id;
}
var batchId = Guid.NewGuid();
var now = DateTime.UtcNow;
for (int i = 0; i < dto.Quantity; i++)
{
var code = await GenerateCertificateCodeAsync(companyId);
var cert = new GiftCertificate
{
CertificateCode = code,
OriginalAmount = dto.Amount,
RedeemedAmount = 0,
IssuedReason = dto.IssuedReason,
Status = GiftCertificateStatus.Active,
IssueDate = now,
ExpiryDate = dto.ExpiryDate,
Notes = dto.Notes,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = now,
CreatedBy = currentUser?.Email,
BatchId = batchId
};
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
else
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
}
return RedirectToAction(nameof(BulkResult), new { batchId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bulk gift certificates");
this.ToastError("An error occurred creating the certificates.");
return View(dto);
}
}
/// <summary>
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
/// </summary>
public async Task<IActionResult> BulkResult(Guid batchId)
{
if (batchId == Guid.Empty)
return RedirectToAction(nameof(Index));
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false);
if (!certs.Any())
return RedirectToAction(nameof(Index));
return View(certs.OrderBy(c => c.CertificateCode).ToList());
}
/// <summary>
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
/// user can bookmark or re-open it at any time after the batch was originally created.
/// </summary>
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
{
if (batchId == Guid.Empty)
return BadRequest();
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false,
gc => gc.RecipientCustomer);
if (!certs.Any())
return NotFound();
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes
}).ToList();
try
{
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
var first = dtos.First().CertificateCode;
var last = dtos.Last().CertificateCode;
var fileName = dtos.Count == 1
? $"GiftCertificate-{first}.pdf"
: $"GiftCertificates-{first}-to-{last}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(BulkResult), new { batchId });
}
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{ {
if (company == null) return (null, null); if (company == null) return (null, null);
@@ -916,8 +916,13 @@ public class KioskController : Controller
ViewBag.SessionToken = session.SessionToken; ViewBag.SessionToken = session.SessionToken;
ViewBag.SessionType = session.SessionType; ViewBag.SessionType = session.SessionType;
// Reset to Welcome screen after 45 s of inactivity on any intake step. // In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
// The Welcome screen itself stays on indefinitely (no timeout override there). // abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
ViewBag.InactivityTimeoutMs = 45_000; // Remote sessions: customer is on their own phone — never redirect; they may
// take several minutes between steps and have no KioskDevice cookie anyway.
if (session.SessionType == KioskSessionType.InPerson)
ViewBag.InactivityTimeoutMs = 45_000;
else
ViewBag.ShowInactivityTimer = false;
} }
} }
@@ -232,4 +232,3 @@
}); });
</script> </script>
} }
@@ -4,7 +4,7 @@
ViewData["Title"] = "Edit Bill"; ViewData["Title"] = "Edit Bill";
ViewData["PageIcon"] = "bi-pencil-square"; ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Bill"; ViewData["PageHelpTitle"] = "Edit Bill";
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation."; ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked Void the bill and recreate it if corrections are needed after confirmation.";
} }
<div class="d-flex justify-content-start mb-4"> <div class="d-flex justify-content-start mb-4">
@@ -24,7 +24,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Bill Details" data-bs-title="Bill Details"
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation."> data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -34,8 +34,8 @@
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label> <label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" <select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor"> data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value="">— Select Vendor —</option> <option value=""> Select Vendor </option>
<option value="__new__">+ Add New Vendor…</option> <option value="__new__">+ Add New Vendor</option>
</select> </select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -87,7 +87,7 @@
} }
<input type="file" name="receiptFile" id="receiptFile" class="form-control" <input type="file" name="receiptFile" id="receiptFile" class="form-control"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" /> accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div> <div class="form-text">JPG, PNG, GIF, WebP, or PDF up to 10 MB.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -100,7 +100,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Line Items" data-bs-title="Line Items"
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories."> data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -134,7 +134,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bill Summary" data-bs-title="Bill Summary"
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid."> data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed each payment recorded reduces the balance due until the bill is fully paid.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -171,7 +171,7 @@
<tr class="line-item-row"> <tr class="line-item-row">
<td> <td>
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required> <select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
<option value="">— Account —</option> <option value=""> Account </option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts) @foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{ {
<option value="@item.Value">@item.Text</option> <option value="@item.Value">@item.Text</option>
@@ -181,7 +181,7 @@
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td> <td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
<td> <td>
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId"> <select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
<option value="">—</option> <option value=""></option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs) @foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
{ {
<option value="@item.Value">@item.Text</option> <option value="@item.Value">@item.Text</option>
@@ -0,0 +1,107 @@
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bulk Create Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
</h5>
<p class="text-muted small mb-0 mt-1">
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
face value and be generated with sequential codes ready to print.
</p>
</div>
<div class="card-body p-4">
<form asp-action="BulkCreate" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="row g-3">
<div class="col-md-5">
<label asp-for="Quantity" class="form-label fw-semibold">
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
</label>
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
min="1" max="500" placeholder="25" />
<span asp-validation-for="Quantity" class="text-danger small"></span>
<div class="form-text">Max 500 per batch.</div>
</div>
<div class="col-md-7">
<label asp-for="Amount" class="form-label fw-semibold">
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
</label>
<div class="input-group input-group-lg">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" class="form-control"
min="1" max="9999.99" step="0.01" placeholder="50.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="IssuedReason" class="form-label fw-semibold">
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
</label>
<select asp-for="IssuedReason" class="form-select">
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
{
<option value="@reason">@reason</option>
}
</select>
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="ExpiryDate" class="form-label fw-semibold">
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
</label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
<div class="form-text">Leave blank for no expiration.</div>
</div>
<div class="col-12">
<label asp-for="Notes" class="form-label fw-semibold">
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
</label>
<textarea asp-for="Notes" class="form-control" rows="2"
placeholder="e.g. Awarded at the 2026 Summer Car Show &mdash; thanks for attending!"></textarea>
<span asp-validation-for="Notes" class="text-danger small"></span>
<div class="form-text">Printed on every certificate in the batch.</div>
</div>
</div>
<!-- Preview summary -->
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
<i class="bi bi-info-circle me-2"></i>
You are about to create <strong id="prevQty"></strong> certificates worth
<strong id="prevAmt"></strong> each &mdash; total face value
<strong id="prevTotal"></strong>.
</div>
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-plus-circle me-2"></i>Create Certificates
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,76 @@
@model List<PowderCoating.Core.Entities.GiftCertificate>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Batch Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
var count = Model.Count;
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
}
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>@count gift certificates created</strong> &mdash; each worth @amount.ToString("C").
Download the PDF below to print the full batch. This page is bookmarkable &mdash; you can return here any time to re-download.
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]&hellip;</span>
</h5>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Certificate Code</th>
<th>Face Value</th>
<th>Issued</th>
<th>Expiry</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in Model)
{
<tr>
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
<td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td>
@(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;")
</td>
<td><span class="badge bg-success">Active</span></td>
<td class="text-end">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
<i class="bi bi-file-pdf"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
</a>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
</a>
</div>
</div>
@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4"> <div class="alert alert-@statusClass d-flex align-items-center mb-4">
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i> <i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
<div> <div>
<strong>@statusLabel</strong> <strong>@statusLabel</strong>
@@ -38,7 +38,7 @@
} }
@if (Model.ExpiryDate.HasValue) @if (Model.ExpiryDate.HasValue)
{ {
<span class="ms-2 small">&middot; Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span> <span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
} }
</div> </div>
</div> </div>
@@ -7,10 +7,15 @@
} }
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p> <p class="text-muted mb-0">@ViewBag.TotalActive active certificates &mdash; @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<a asp-action="Create" class="btn btn-primary"> <div class="d-flex gap-2">
<i class="bi bi-plus-circle me-2"></i>New Certificate <a asp-action="BulkCreate" class="btn btn-outline-primary">
</a> <i class="bi bi-collection me-2"></i>Bulk Create
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -75,6 +80,14 @@ else
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace"> <a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
@cert.CertificateCode @cert.CertificateCode
</a> </a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
title="View &amp; download batch">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</td> </td>
<td> <td>
@if (!string.IsNullOrEmpty(cert.RecipientName)) @if (!string.IsNullOrEmpty(cert.RecipientName))
@@ -83,7 +96,7 @@ else
} }
else else
{ {
<span class="text-muted"></span> <span class="text-muted">&mdash;</span>
} }
@if (!string.IsNullOrEmpty(cert.RecipientEmail)) @if (!string.IsNullOrEmpty(cert.RecipientEmail))
{ {
@@ -168,6 +168,23 @@
} }
.reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; } .reason-pill.selected { border-color: var(--purple); background: #f3effe; color: var(--purple); font-weight: 600; }
/* ── Input mode toggle ───────────────────────── */
.mode-toggle { display: flex; border: 1.5px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 18px; }
.mode-btn {
flex: 1;
padding: 10px 8px;
background: #fff;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
text-align: center;
transition: background .15s, color .15s;
}
.mode-btn.active { background: var(--purple); color: #fff; }
.mode-btn:first-child { border-right: 1.5px solid var(--border); }
/* ── Submit / Cancel ─────────────────────────── */ /* ── Submit / Cancel ─────────────────────────── */
.btn-submit { .btn-submit {
width: 100%; width: 100%;
@@ -309,12 +326,28 @@
<div class="form-card"> <div class="form-card">
<h2>2. Enter Quantity</h2> <h2>2. Enter Quantity</h2>
<div class="field">
<div class="mode-toggle">
<button type="button" class="mode-btn active" id="modeUsed" onclick="setMode('used')">Amount Used</button>
<button type="button" class="mode-btn" id="modeRemaining" onclick="setMode('remaining')">Remaining Weight</button>
</div>
<!-- amount-used mode -->
<div id="usedField" class="field">
<label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label> <label for="quantityInput">Amount Used (@item.UnitOfMeasure) <span class="req">*</span></label>
<input type="number" id="quantityInput" name="quantity" <input type="number" id="quantityInput" name="quantity"
min="0" step="any" required placeholder="0" inputmode="decimal" /> min="0" step="any" placeholder="0" inputmode="decimal"
oninvalid="this.setCustomValidity('')" />
<div class="hint" id="balanceHint"></div> <div class="hint" id="balanceHint"></div>
</div> </div>
<!-- remaining-weight mode -->
<div id="remainingField" class="field" style="display:none">
<label for="remainingInput">Weight Remaining (@item.UnitOfMeasure) <span class="req">*</span></label>
<input type="number" id="remainingInput" min="0" step="any"
placeholder="0" inputmode="decimal" />
<div class="hint" id="remainingHint"></div>
</div>
</div> </div>
<div class="form-card"> <div class="form-card">
@@ -346,6 +379,21 @@
<script> <script>
var currentQty = @item.QuantityOnHand; var currentQty = @item.QuantityOnHand;
var uom = '@item.UnitOfMeasure'; var uom = '@item.UnitOfMeasure';
var inputMode = 'used'; // 'used' | 'remaining'
// ── Input mode toggle ────────────────────────────
function setMode(mode) {
inputMode = mode;
document.getElementById('modeUsed').classList.toggle('active', mode === 'used');
document.getElementById('modeRemaining').classList.toggle('active', mode === 'remaining');
document.getElementById('usedField').style.display = mode === 'used' ? '' : 'none';
document.getElementById('remainingField').style.display = mode === 'remaining' ? '' : 'none';
document.getElementById('balanceHint').textContent = '';
document.getElementById('remainingHint').textContent = '';
// clear both inputs when switching
document.getElementById('quantityInput').value = '';
document.getElementById('remainingInput').value = '';
}
// ── Job selection ──────────────────────────────── // ── Job selection ────────────────────────────────
function showTab(tab) { function showTab(tab) {
@@ -384,7 +432,7 @@
document.getElementById('transactionTypeInput').value = el.dataset.val; document.getElementById('transactionTypeInput').value = el.dataset.val;
} }
// ── Balance hint ───────────────────────────────── // ── Balance hint (amount-used mode) ─────────────
document.getElementById('quantityInput').addEventListener('input', function() { document.getElementById('quantityInput').addEventListener('input', function() {
var qty = parseFloat(this.value) || 0; var qty = parseFloat(this.value) || 0;
if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; } if (!this.value) { document.getElementById('balanceHint').textContent = ''; return; }
@@ -394,6 +442,24 @@
'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>'; 'New balance: <strong style="color:' + col + '">' + newBal.toFixed(2) + ' ' + uom + '</strong>';
}); });
// ── Remaining-weight hint ────────────────────────
document.getElementById('remainingInput').addEventListener('input', function() {
var hint = document.getElementById('remainingHint');
if (!this.value) { hint.textContent = ''; return; }
var remaining = parseFloat(this.value);
if (isNaN(remaining) || remaining < 0) { hint.innerHTML = '<span style="color:var(--danger)">Enter a valid weight.</span>'; return; }
if (remaining > currentQty) {
hint.innerHTML = '<span style="color:var(--danger)">Remaining cannot exceed current stock (' + currentQty.toFixed(2) + ' ' + uom + ').</span>';
return;
}
var used = currentQty - remaining;
if (used <= 0) {
hint.innerHTML = '<span style="color:var(--danger)">No usage to log &mdash; remaining equals current stock.</span>';
return;
}
hint.innerHTML = 'Will log <strong>' + used.toFixed(2) + ' ' + uom + '</strong> as used &mdash; new balance: <strong style="color:' + (remaining === 0 ? '#343a40' : 'var(--success)') + '">' + remaining.toFixed(2) + ' ' + uom + '</strong>';
});
// ── Preselect job if coming from success page ──── // ── Preselect job if coming from success page ────
@if (preselectedJobId.HasValue) @if (preselectedJobId.HasValue)
{ {
@@ -406,8 +472,37 @@
</text> </text>
} }
// ── Submit spinner ─────────────────────────────── // ── Submit: resolve quantity from whichever mode is active ──
document.getElementById('usageForm').addEventListener('submit', function() { document.getElementById('usageForm').addEventListener('submit', function(e) {
if (inputMode === 'remaining') {
var remaining = parseFloat(document.getElementById('remainingInput').value);
if (isNaN(remaining) || remaining < 0 || remaining > currentQty) {
e.preventDefault();
document.getElementById('remainingHint').innerHTML =
'<span style="color:var(--danger)">Please enter a valid remaining weight.</span>';
return;
}
var used = currentQty - remaining;
if (used <= 0) {
e.preventDefault();
document.getElementById('remainingHint').innerHTML =
'<span style="color:var(--danger)">No usage to log &mdash; remaining equals current stock.</span>';
return;
}
document.getElementById('quantityInput').value = used.toFixed(4);
}
// validate amount-used mode
if (inputMode === 'used') {
var qty = parseFloat(document.getElementById('quantityInput').value);
if (isNaN(qty) || qty <= 0) {
e.preventDefault();
document.getElementById('balanceHint').innerHTML =
'<span style="color:var(--danger)">Please enter a quantity greater than zero.</span>';
return;
}
}
var btn = document.getElementById('submitBtn'); var btn = document.getElementById('submitBtn');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Saving…'; btn.textContent = 'Saving…';
@@ -38,7 +38,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Invoice Details" data-bs-title="Invoice Details"
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked."> data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -79,7 +79,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Line Items" data-bs-title="Line Items"
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge)."> data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -163,7 +163,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Notes" data-bs-title="Notes"
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer."> data-bs-content="Customer Notes appear on the printed and emailed invoice use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -144,7 +144,7 @@
</td> </td>
<td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td> <td>@inv.InvoiceDate.ToString("MM/dd/yyyy")</td>
<td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")"> <td class="@(inv.IsOverdue ? "fw-bold text-danger" : "")">
@(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "") @(inv.DueDate.HasValue ? inv.DueDate.Value.ToString("MM/dd/yyyy") : "&mdash;")
</td> </td>
<td class="text-end">@inv.Total.ToString("C")</td> <td class="text-end">@inv.Total.ToString("C")</td>
<td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")"> <td class="text-end @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@@ -167,6 +167,77 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var inv in Model.Items)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Invoices", new { id = inv.Id })'">
<div class="mobile-card-header" style="@(inv.IsOverdue ? "background:#fee2e2;" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-receipt"></i>
</div>
<div class="mobile-card-title">
<h6>@inv.InvoiceNumber</h6>
<small>@inv.CustomerName</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.InvoiceStatus(inv.Status), Text: InvoicesController.GetStatusDisplay(inv.Status)))
</span>
</div>
@if (inv.JobId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Job</span>
<span class="mobile-card-value">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@inv.JobId"
class="text-decoration-none" onclick="event.stopPropagation()">
@inv.JobNumber
</a>
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Date</span>
<span class="mobile-card-value">@inv.InvoiceDate.ToString("MM/dd/yy")</span>
</div>
@if (inv.DueDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value @(inv.IsOverdue ? "fw-bold text-danger" : "")">
@inv.DueDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value">@inv.Total.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Balance Due</span>
<span class="mobile-card-value @(inv.BalanceDue > 0 ? "fw-semibold" : "text-muted")">
@inv.BalanceDue.ToString("C")
</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="DownloadPdf" asp-route-id="@inv.Id"
class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-file-pdf me-1"></i>PDF
</a>
</div>
</div>
}
</div>
</div>
<div class="px-3"> <div class="px-3">
@await Html.PartialAsync("_Pagination", Model) @await Html.PartialAsync("_Pagination", Model)
</div> </div>
@@ -19,7 +19,7 @@
<i class="bi bi-layout-text-window-reverse fs-5"></i> <i class="bi bi-layout-text-window-reverse fs-5"></i>
<div> <div>
Pre-filled from template <strong>@ViewBag.TemplateName</strong>. Pre-filled from template <strong>@ViewBag.TemplateName</strong>.
Items and coatings have been loaded — review and adjust before saving. Items and coatings have been loaded review and adjust before saving.
</div> </div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button> <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div> </div>
@@ -50,7 +50,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Details" data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker."> data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order include it so it appears on invoices. Special Instructions go directly to the shop floor worker.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -70,7 +70,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Priority" data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team."> data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -100,7 +100,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Due Date" data-bs-title="Due Date"
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> data-bs-content="The customer's deadline when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -130,7 +130,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Special Instructions" data-bs-title="Special Instructions"
data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'."> data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes e.g., 'Keep brackets separated, customer allergic to zinc primer'.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -201,7 +201,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Items" data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -276,7 +276,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
+5 -5
View File
@@ -26,7 +26,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Details" data-bs-title="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number — it appears on invoices. Special Instructions go directly to the shop floor worker on the work order."> data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number it appears on invoices. Special Instructions go directly to the shop floor worker on the work order.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -57,7 +57,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Priority" data-bs-title="Job Priority"
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team."> data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint overuse of Rush dilutes its meaning for the shop floor team.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</label> </label>
@@ -85,7 +85,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Due Date" data-bs-title="Due Date"
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list."> data-bs-content="The customer's deadline when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</label> </label>
@@ -170,7 +170,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Job Items" data-bs-title="Job Items"
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work."> data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -245,7 +245,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -2,7 +2,7 @@
@using PowderCoating.Core.Entities @using PowderCoating.Core.Entities
@{ @{
ViewData["Title"] = $"Edit Items — {Model.JobNumber}"; ViewData["Title"] = $"Edit Items {Model.JobNumber}";
ViewData["PageIcon"] = "bi-list-check"; ViewData["PageIcon"] = "bi-list-check";
} }
@@ -67,7 +67,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
+117 -14
View File
@@ -5,7 +5,7 @@
string activeFilter = ViewBag.ActiveFilter as string ?? "all"; string activeFilter = ViewBag.ActiveFilter as string ?? "all";
} }
<div class="container-fluid px-4"> <div>
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2"> <div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<i class="bi bi-clipboard-check fs-3 text-primary"></i> <i class="bi bi-clipboard-check fs-3 text-primary"></i>
@@ -53,17 +53,116 @@
else else
{ {
<div class="card"> <div class="card">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var s in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-clipboard-check"></i>
</div>
<div class="mobile-card-title">
<h6>@s.CustomerFullName</h6>
<small>@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (s.Status == KioskSessionStatus.Submitted && s.IsConverted)
{
<span class="badge bg-success">Converted</span>
}
else if (s.Status == KioskSessionStatus.Submitted)
{
<span class="badge bg-info text-dark">Submitted</span>
}
else if (s.Status == KioskSessionStatus.Active && !s.IsExpired)
{
<span class="badge bg-warning text-dark">In Progress</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
@if (s.SessionType == KioskSessionType.InPerson)
{
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-tablet me-1"></i>In-Person</span>
}
else
{
<span class="badge" style="background:#ede9fe;color:#6d28d9;"><i class="bi bi-envelope me-1"></i>Remote</span>
}
</span>
</div>
@if (!string.IsNullOrEmpty(s.CustomerPhone))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Phone</span>
<span class="mobile-card-value"><a href="tel:@s.CustomerPhone">@s.CustomerPhone</a></span>
</div>
}
@if (!string.IsNullOrEmpty(s.CustomerEmail))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Email</span>
<span class="mobile-card-value" style="white-space:normal;"><a href="mailto:@s.CustomerEmail">@s.CustomerEmail</a></span>
</div>
}
@if (s.LinkedCustomerId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Matched</span>
<span class="mobile-card-value">
<a href="/Customers/Details/@s.LinkedCustomerId" class="text-success">
<i class="bi bi-person-check me-1"></i>Customer record
</a>
</span>
</div>
}
</div>
<div class="mobile-card-footer">
@if (s.LinkedJobId.HasValue)
{
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success">
<i class="bi bi-briefcase me-1"></i>Job
</a>
}
@if (s.LinkedQuoteId.HasValue)
{
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info">
<i class="bi bi-file-earmark-text me-1"></i>Quote
</a>
}
@if (s.LinkedCustomerId.HasValue)
{
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle"> <table class="table table-hover mb-0 align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Date</th> <th class="d-none d-md-table-cell">Date</th>
<th>Customer</th> <th>Customer</th>
<th>Contact</th> <th class="d-none d-lg-table-cell">Contact</th>
<th>Project</th> <th class="d-none d-lg-table-cell">Project</th>
<th>Type</th> <th class="d-none d-sm-table-cell">Type</th>
<th>Status</th> <th>Status</th>
<th>SMS</th> <th class="d-none d-md-table-cell">SMS</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -71,7 +170,7 @@
@foreach (var s in Model) @foreach (var s in Model)
{ {
<tr> <tr>
<td class="text-nowrap text-muted small"> <td class="text-nowrap text-muted small d-none d-md-table-cell">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt")) @(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</td> </td>
<td> <td>
@@ -82,8 +181,12 @@
<i class="bi bi-person-check me-1"></i>Customer matched <i class="bi bi-person-check me-1"></i>Customer matched
</a> </a>
} }
@* Show date inline on mobile since the Date column is hidden *@
<div class="text-muted small d-md-none">
@(s.SubmittedAt?.ToLocalTime().ToString("MM/dd/yy h:mm tt") ?? s.ExpiresAt.AddHours(-2).ToLocalTime().ToString("MM/dd/yy h:mm tt"))
</div>
</td> </td>
<td class="small text-muted"> <td class="small text-muted d-none d-lg-table-cell">
@if (!string.IsNullOrEmpty(s.CustomerPhone)) @if (!string.IsNullOrEmpty(s.CustomerPhone))
{ {
<div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div> <div><i class="bi bi-telephone me-1"></i>@s.CustomerPhone</div>
@@ -93,11 +196,11 @@
<div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div> <div><i class="bi bi-envelope me-1"></i>@s.CustomerEmail</div>
} }
</td> </td>
<td style="max-width:280px;"> <td class="d-none d-lg-table-cell" style="max-width:280px;">
<span class="text-truncate d-block" style="max-width:260px;" <span class="text-truncate d-block" style="max-width:260px;"
title="@s.JobDescription">@s.JobDescriptionSnippet</span> title="@s.JobDescription">@s.JobDescriptionSnippet</span>
</td> </td>
<td> <td class="d-none d-sm-table-cell">
@if (s.SessionType == KioskSessionType.InPerson) @if (s.SessionType == KioskSessionType.InPerson)
{ {
<span class="badge bg-primary-subtle text-primary"> <span class="badge bg-primary-subtle text-primary">
@@ -129,7 +232,7 @@
<span class="badge bg-secondary">Expired</span> <span class="badge bg-secondary">Expired</span>
} }
</td> </td>
<td> <td class="d-none d-md-table-cell">
@if (s.SmsOptIn) @if (s.SmsOptIn)
{ {
<i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i> <i class="bi bi-check-circle-fill text-success" title="SMS opt-in"></i>
@@ -143,19 +246,19 @@
@if (s.LinkedJobId.HasValue) @if (s.LinkedJobId.HasValue)
{ {
<a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1"> <a href="/Jobs/Details/@s.LinkedJobId" class="btn btn-sm btn-outline-success me-1">
<i class="bi bi-briefcase me-1"></i>View Job <i class="bi bi-briefcase me-1"></i><span class="d-none d-sm-inline">View Job</span><span class="d-sm-none">Job</span>
</a> </a>
} }
@if (s.LinkedQuoteId.HasValue) @if (s.LinkedQuoteId.HasValue)
{ {
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1"> <a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
<i class="bi bi-file-earmark-text me-1"></i>View Quote <i class="bi bi-file-earmark-text me-1"></i><span class="d-none d-sm-inline">View Quote</span><span class="d-sm-none">Quote</span>
</a> </a>
} }
@if (s.LinkedCustomerId.HasValue) @if (s.LinkedCustomerId.HasValue)
{ {
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary"> <a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
<i class="bi bi-person me-1"></i>Customer <i class="bi bi-person me-1"></i><span class="d-none d-sm-inline">Customer</span>
</a> </a>
} }
</td> </td>
@@ -85,7 +85,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Personal Information" data-bs-title="Personal Information"
data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference †change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here."> data-bs-content="First Name, Last Name, and Phone are editable and saved when you click Save Profile. Email is shown here for reference â€" change it on the Security tab. Department, Position, Role, and Employee Number are set by an administrator and cannot be changed here.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -290,7 +290,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Appearance Settings" data-bs-title="Appearance Settings"
data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background †click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices."> data-bs-content="Theme switches the app between Light and Dark mode. Sidebar Color changes the navigation panel background â€" click a swatch to preview it instantly. Date Format controls how dates display throughout the app. Timezone is used to localize timestamps. Click Save Appearance to persist your choices.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -364,39 +364,39 @@
<label class="form-label fw-semibold">Timezone</label> <label class="form-label fw-semibold">Timezone</label>
<select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;"> <select class="form-select" id="timezoneInput" name="TimeZone" style="max-width:350px;">
<optgroup label="United States"> <optgroup label="United States">
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) — New York</option> <option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) New York</option>
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) — Chicago</option> <option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) Chicago</option>
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) — Denver</option> <option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) Denver</option>
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST — Phoenix</option> <option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST Phoenix</option>
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) — Los Angeles</option> <option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) Los Angeles</option>
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) — Anchorage</option> <option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) Anchorage</option>
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) — Honolulu</option> <option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) Honolulu</option>
</optgroup> </optgroup>
<optgroup label="Canada"> <optgroup label="Canada">
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) — Halifax</option> <option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) Halifax</option>
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern — Toronto</option> <option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern Toronto</option>
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central — Winnipeg</option> <option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central Winnipeg</option>
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain — Edmonton</option> <option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain Edmonton</option>
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific — Vancouver</option> <option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific Vancouver</option>
</optgroup> </optgroup>
<optgroup label="Europe"> <optgroup label="Europe">
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST — London</option> <option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST London</option>
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET — Paris / Berlin</option> <option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET Paris / Berlin</option>
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET — Helsinki</option> <option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET Helsinki</option>
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK — Moscow</option> <option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK Moscow</option>
</optgroup> </optgroup>
<optgroup label="Asia / Pacific"> <optgroup label="Asia / Pacific">
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST — Dubai</option> <option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST Dubai</option>
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST — India</option> <option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST India</option>
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT — Bangkok</option> <option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT Bangkok</option>
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST — Beijing / Shanghai</option> <option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST Beijing / Shanghai</option>
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST — Tokyo</option> <option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST Tokyo</option>
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST — Sydney</option> <option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST Sydney</option>
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST — Auckland</option> <option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST Auckland</option>
</optgroup> </optgroup>
<optgroup label="South America"> <optgroup label="South America">
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option> <option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT — São Paulo</option>
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART — Buenos Aires</option> <option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART Buenos Aires</option>
</optgroup> </optgroup>
<optgroup label="UTC"> <optgroup label="UTC">
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option> <option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
@@ -538,7 +538,7 @@
}); });
}); });
// Theme radio †map light/dark → paper/ink surface system // Theme radio â€" map light/dark → paper/ink surface system
document.querySelectorAll('input[name="theme"]').forEach(radio => { document.querySelectorAll('input[name="theme"]').forEach(radio => {
radio.addEventListener('change', function () { radio.addEventListener('change', function () {
var surface = this.value === 'dark' ? 'ink' : 'paper'; var surface = this.value === 'dark' ? 'ink' : 'paper';
@@ -51,7 +51,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Customer vs Prospect/Walk-In" data-bs-title="Customer vs Prospect/Walk-In"
data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -146,7 +146,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information" data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -210,7 +210,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing" data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings."> data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -253,7 +253,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types" data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -314,7 +314,7 @@
<div class="form-check"> <div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" /> <input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer"> <label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer — PDFs and approval portal show final price only Hide discount from customer PDFs and approval portal show final price only
</label> </label>
</div> </div>
</div> </div>
@@ -329,7 +329,7 @@
<a tabindex="0" class="help-icon text-white" role="button" <a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary" data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -340,7 +340,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -379,7 +379,7 @@
<div class="row g-3" id="stagedPhotoGrid"></div> <div class="row g-3" id="stagedPhotoGrid"></div>
<div id="stagedPhotoUploadProgress" class="d-none mt-2"> <div id="stagedPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div> <div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading…</small> <small class="text-muted">Uploading</small>
</div> </div>
</div> </div>
</div> </div>
@@ -560,7 +560,7 @@
function applyMode(mode) { function applyMode(mode) {
if (mode === 'simple') { if (mode === 'simple') {
form.classList.add('quote-simple-mode'); form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden — switch to Full Quote to see them.'; hint.textContent = 'Advanced fields are hidden switch to Full Quote to see them.';
} else { } else {
form.classList.remove('quote-simple-mode'); form.classList.remove('quote-simple-mode');
hint.textContent = ''; hint.textContent = '';
@@ -628,8 +628,8 @@
smsNote.style.display = 'inline'; smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark'; smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details' ? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required'; : '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
} else { } else {
smsNote.style.display = 'none'; smsNote.style.display = 'none';
} }
+13 -13
View File
@@ -109,7 +109,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information" data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -173,7 +173,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing" data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings."> data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -216,7 +216,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types" data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -277,7 +277,7 @@
<div class="form-check"> <div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" /> <input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer"> <label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer — PDFs and approval portal show final price only Hide discount from customer PDFs and approval portal show final price only
</label> </label>
</div> </div>
</div> </div>
@@ -292,7 +292,7 @@
<a tabindex="0" class="help-icon text-white" role="button" <a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary" data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;"> data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</h5> </h5>
@@ -303,7 +303,7 @@
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p> <p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p> <p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
<p class="mb-1 d-none" id="ovenBatchCostRow"> <p class="mb-1 d-none" id="ovenBatchCostRow">
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min): <i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
<strong id="ovenBatchCostDisplay">$0.00</strong> <strong id="ovenBatchCostDisplay">$0.00</strong>
</p> </p>
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow"> <p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
@@ -407,7 +407,7 @@
</div> </div>
<div id="editPhotoUploadProgress" class="d-none mt-2"> <div id="editPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div> <div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading…</small> <small class="text-muted">Uploading</small>
</div> </div>
</div> </div>
</div> </div>
@@ -435,11 +435,11 @@
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")"> <span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms) @if (editHasSms)
{ {
<i class="bi bi-phone me-1"></i><text>No email — send via SMS from quote details</text> <i class="bi bi-phone me-1"></i><text>No email send via SMS from quote details</text>
} }
else else
{ {
<i class="bi bi-phone-slash me-1"></i><text>No email — SMS consent required</text> <i class="bi bi-phone-slash me-1"></i><text>No email SMS consent required</text>
} }
</span> </span>
} }
@@ -488,7 +488,7 @@
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>())) @Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script> </script>
<!-- Existing items — always populated on Edit --> <!-- Existing items always populated on Edit -->
<script id="existingItemsData" type="application/json"> <script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new { @Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
description = item.Description, description = item.Description,
@@ -613,8 +613,8 @@
smsNote.style.display = 'inline'; smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark'; smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details' ? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required'; : '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
} else { } else {
smsNote.style.display = 'none'; smsNote.style.display = 'none';
} }
@@ -639,7 +639,7 @@
} }
}); });
// Quote photo direct upload (Edit page — quoteId is known) // Quote photo direct upload (Edit page quoteId is known)
(function () { (function () {
const quoteId = @Model.Id; const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")'; const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
@@ -0,0 +1,37 @@
(function () {
var qtyInput = document.getElementById('Quantity');
var amtInput = document.getElementById('Amount');
var preview = document.getElementById('batchPreview');
var prevQty = document.getElementById('prevQty');
var prevAmt = document.getElementById('prevAmt');
var prevTotal = document.getElementById('prevTotal');
function updatePreview() {
var qty = parseInt(qtyInput.value, 10);
var amt = parseFloat(amtInput.value);
if (qty > 0 && amt > 0) {
prevQty.textContent = qty;
prevAmt.textContent = '$' + amt.toFixed(2);
prevTotal.textContent = '$' + (qty * amt).toFixed(2);
preview.style.display = '';
} else {
preview.style.display = 'none';
}
}
if (qtyInput && amtInput) {
qtyInput.addEventListener('input', updatePreview);
amtInput.addEventListener('input', updatePreview);
updatePreview();
}
// Disable submit button after first click to prevent double-submit during long creation
var form = document.querySelector('form');
var submitBtn = document.getElementById('submitBtn');
if (form && submitBtn) {
form.addEventListener('submit', function () {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
});
}
}());