711cd01cd3
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges - Customer Notes log: inline add/delete notes with important flag, AJAX-backed - Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions - Preferred Powders per customer: typeahead inventory search, AJAX add/remove - CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic - Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
842 lines
49 KiB
Plaintext
842 lines
49 KiB
Plaintext
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
|
|
|
@{
|
|
ViewData["Title"] = !string.IsNullOrWhiteSpace(Model.CompanyName)
|
|
? Model.CompanyName
|
|
: $"{Model.ContactFirstName} {Model.ContactLastName}".Trim();
|
|
ViewData["PageIcon"] = "bi-person-circle";
|
|
}
|
|
|
|
<div class="row justify-content-center">
|
|
<div class="col-lg-10">
|
|
<div class="d-flex justify-content-end gap-2 mb-4">
|
|
<div class="d-flex gap-2">
|
|
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-outline-info">
|
|
<i class="bi bi-clock-history me-2"></i>View Activity
|
|
</a>
|
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
|
<i class="bi bi-pencil me-2"></i>Edit
|
|
</a>
|
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left me-2"></i>Back to List
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Banner -->
|
|
<div class="alert @(Model.IsActive ? "alert-success" : "alert-danger") alert-permanent d-flex align-items-center mb-4">
|
|
<i class="bi @(Model.IsActive ? "bi-check-circle" : "bi-x-circle") me-2" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<strong>Status:</strong> @(Model.IsActive ? "Active Customer" : "Inactive Customer")
|
|
@if (!Model.IsActive)
|
|
{
|
|
<span class="ms-2">- This customer is currently inactive</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4">
|
|
<!-- Left Column -->
|
|
<div class="col-lg-8">
|
|
<!-- Company Information -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-building me-2 text-primary"></i>Company Information
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-8">
|
|
<label class="text-muted small mb-1">Company Name</label>
|
|
<p class="fw-semibold mb-0">@Model.CompanyName</p>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="text-muted small mb-1">Customer Type</label>
|
|
<p class="mb-0">
|
|
@if (Model.IsCommercial)
|
|
{
|
|
<span class="badge bg-primary bg-opacity-10 text-primary">
|
|
<i class="bi bi-building me-1"></i>Commercial
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">
|
|
<i class="bi bi-person me-1"></i>Individual
|
|
</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact Information -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-person me-2 text-primary"></i>Contact Information
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Contact Name</label>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.ContactFirstName) || !string.IsNullOrEmpty(Model.ContactLastName))
|
|
{
|
|
<span>@Model.ContactFirstName @Model.ContactLastName</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not provided</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Email</label>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.Email))
|
|
{
|
|
<a href="mailto:@Model.Email" class="text-decoration-none">
|
|
<i class="bi bi-envelope me-1"></i>@Model.Email
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not provided</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
@if (Model.IsCommercial)
|
|
{
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Billing / Accounting Email</label>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.BillingEmail))
|
|
{
|
|
<a href="mailto:@Model.BillingEmail" class="text-decoration-none">
|
|
<i class="bi bi-envelope-at me-1"></i>@Model.BillingEmail
|
|
</a>
|
|
<span class="badge bg-info bg-opacity-10 text-info ms-2 small">Invoices</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not set — invoices go to contact email</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
}
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Phone</label>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.Phone))
|
|
{
|
|
<a href="tel:@Model.Phone" class="text-decoration-none">
|
|
<i class="bi bi-telephone me-1"></i>@Model.Phone
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not provided</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Mobile Phone</label>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.MobilePhone))
|
|
{
|
|
<a href="tel:@Model.MobilePhone" class="text-decoration-none">
|
|
<i class="bi bi-phone me-1"></i>@Model.MobilePhone
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">Not provided</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="text-muted small mb-1">Notifications</label>
|
|
<div class="d-flex gap-2">
|
|
@if (Model.NotifyByEmail)
|
|
{
|
|
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
|
|
<i class="bi bi-envelope-fill me-1"></i>Email on
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
|
|
<i class="bi bi-envelope-slash me-1"></i>Email off
|
|
</span>
|
|
}
|
|
<span id="sms-status-section">
|
|
@if (Model.NotifyBySms)
|
|
{
|
|
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
|
|
title="@(Model.SmsConsentedAt.HasValue ? "Consented " + Model.SmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy") : "")">
|
|
<i class="bi bi-chat-fill me-1"></i>SMS on
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
|
|
<i class="bi bi-chat-slash me-1"></i>SMS off
|
|
</span>
|
|
<button type="button" id="btnGetSmsConsent"
|
|
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
|
|
style="cursor:pointer;"
|
|
title="Send SMS consent form to the front-desk kiosk tablet"
|
|
onclick="pushSmsConsent(@Model.Id)">
|
|
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
|
|
</button>
|
|
<button type="button" id="btnCancelSmsConsent"
|
|
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
|
|
style="cursor:pointer;"
|
|
title="Cancel the pending kiosk consent request"
|
|
onclick="cancelSmsConsent()">
|
|
<i class="bi bi-x-circle me-1"></i>Cancel Consent
|
|
</button>
|
|
}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address Information -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-geo-alt me-2 text-primary"></i>Address
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
@if (!string.IsNullOrEmpty(Model.Address))
|
|
{
|
|
<p class="mb-2">@Model.Address</p>
|
|
<p class="mb-0">
|
|
@if (!string.IsNullOrEmpty(Model.City))
|
|
{
|
|
<span>@Model.City</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(Model.State))
|
|
{
|
|
<span>, @Model.State</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(Model.ZipCode))
|
|
{
|
|
<span> @Model.ZipCode</span>
|
|
}
|
|
</p>
|
|
@if (!string.IsNullOrEmpty(Model.Country))
|
|
{
|
|
<p class="mb-0 text-muted">@Model.Country</p>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted mb-0">No address provided</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Business Information -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-briefcase me-2 text-primary"></i>Business Information
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Tax ID / EIN</label>
|
|
<p class="mb-0">@(Model.TaxId ?? "Not provided")</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Payment Terms</label>
|
|
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Credit Limit</label>
|
|
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Pricing Tier</label>
|
|
<p class="mb-0">@(Model.PricingTierName ?? "Standard")</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="text-muted small mb-1">Tax Status</label>
|
|
<p class="mb-0">
|
|
@if (Model.IsTaxExempt)
|
|
{
|
|
<span class="badge bg-success">
|
|
<i class="bi bi-check-circle"></i> Tax Exempt
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary">Taxable</span>
|
|
}
|
|
</p>
|
|
</div>
|
|
@if (Model.HasTaxExemptCertificate)
|
|
{
|
|
<div class="col-md-12">
|
|
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
|
|
<div class="alert alert-success alert-permanent d-flex justify-content-between align-items-center mb-0 mt-2">
|
|
<div>
|
|
<i class="bi bi-file-earmark-check me-2"></i>
|
|
<strong>File on record:</strong> @Model.TaxExemptCertificateFileName
|
|
</div>
|
|
<a asp-action="TaxExemptCertificate" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-dark" target="_blank">
|
|
<i class="bi bi-download"></i> Download
|
|
</a>
|
|
</div>
|
|
</div>
|
|
}
|
|
@if (Model.IsTaxExempt && !Model.HasTaxExemptCertificate)
|
|
{
|
|
<div class="col-md-12">
|
|
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
|
|
<div class="alert alert-warning alert-permanent mb-0 mt-2">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
<strong>No certificate on file.</strong> Please upload a tax exempt certificate to complete the record.
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
@if (!string.IsNullOrEmpty(Model.GeneralNotes))
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-journal-text me-2 text-primary"></i>Notes
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="mb-0" style="white-space: pre-wrap;">@Model.GeneralNotes</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Customer Notes -->
|
|
@{
|
|
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
|
|
}
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-sticky me-2 text-primary"></i>Internal Notes
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div id="customer-notes-list">
|
|
@if (customerNotes != null && customerNotes.Count > 0)
|
|
{
|
|
@foreach (var note in customerNotes)
|
|
{
|
|
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
|
|
<div class="flex-grow-1">
|
|
@if (note.IsImportant)
|
|
{
|
|
<span class="text-warning me-1" title="Important">★</span>
|
|
}
|
|
<span class="note-text small">@note.Note</span>
|
|
<div class="text-muted" style="font-size:0.75rem;">
|
|
@(note.CreatedBy ?? "Staff") — @note.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy h:mm tt")
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0 align-self-start"
|
|
onclick="deleteCustomerNote(@Model.Id, @note.Id)" title="Delete note">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>
|
|
}
|
|
</div>
|
|
<div class="px-3 py-3 border-top bg-light">
|
|
<div class="mb-2">
|
|
<textarea id="newNoteText" class="form-control form-control-sm" rows="2"
|
|
placeholder="Add an internal note..." maxlength="2000"></textarea>
|
|
</div>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="form-check form-check-sm mb-0">
|
|
<input class="form-check-input" type="checkbox" id="newNoteImportant">
|
|
<label class="form-check-label small" for="newNoteImportant">
|
|
<span class="text-warning">★</span> Mark important
|
|
</label>
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-primary" onclick="addCustomerNote(@Model.Id)">
|
|
<i class="bi bi-plus-circle me-1"></i>Add Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Activity Timeline -->
|
|
@{
|
|
var timeline = ViewBag.Timeline as List<PowderCoating.Application.DTOs.Customer.CustomerTimelineEventDto>;
|
|
}
|
|
@if (timeline != null && timeline.Count > 0)
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-clock-history me-2 text-primary"></i>Recent Activity
|
|
</h5>
|
|
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
|
|
View All
|
|
</a>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
@foreach (var ev in timeline)
|
|
{
|
|
var hasLink = ev.LinkController != null && ev.EntityId.HasValue;
|
|
var rowTag = hasLink ? "a" : "div";
|
|
var href = hasLink
|
|
? Url.Action(ev.LinkAction, ev.LinkController, new { id = ev.EntityId })
|
|
: null;
|
|
<div class="d-flex align-items-start gap-3 px-3 py-3 border-bottom @(hasLink ? "timeline-row" : "")">
|
|
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 mt-1"
|
|
style="width:34px;height:34px;background:var(--bs-@(ev.BadgeColor)-bg-subtle,#f0f0f0);">
|
|
<i class="bi @ev.Icon text-@ev.BadgeColor" style="font-size:0.9rem;"></i>
|
|
</div>
|
|
<div class="flex-grow-1 min-width-0">
|
|
@if (hasLink)
|
|
{
|
|
<a asp-controller="@ev.LinkController" asp-action="@ev.LinkAction" asp-route-id="@ev.EntityId"
|
|
class="fw-semibold text-decoration-none text-body d-block text-truncate">@ev.Title</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="fw-semibold d-block text-truncate">@ev.Title</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(ev.Subtitle))
|
|
{
|
|
<span class="text-muted small d-block text-truncate">@ev.Subtitle</span>
|
|
}
|
|
<span class="text-muted" style="font-size:0.75rem;">@ev.Date.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</span>
|
|
</div>
|
|
@if (ev.Amount.HasValue)
|
|
{
|
|
<div class="text-end flex-shrink-0">
|
|
<span class="fw-semibold small">@ev.Amount.Value.ToString("C")</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Right Column - Statistics -->
|
|
<div class="col-lg-4">
|
|
<!-- Financial Summary -->
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-currency-dollar me-2 text-primary"></i>Financial Summary
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="text-muted small mb-1">Outstanding Balance</label>
|
|
<h3 class="mb-0 @(Model.CurrentBalance > 0 ? "text-danger" : "text-success")">
|
|
@Model.CurrentBalance.ToString("C")
|
|
</h3>
|
|
</div>
|
|
@if (Model.CreditLimit > 0)
|
|
{
|
|
<div class="mb-3">
|
|
<label class="text-muted small mb-1">Credit Limit</label>
|
|
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="text-muted small mb-1">Available Credit</label>
|
|
<p class="mb-0 fw-semibold text-success">
|
|
@((Model.CreditLimit - Model.CurrentBalance).ToString("C"))
|
|
</p>
|
|
</div>
|
|
}
|
|
<hr class="my-2" />
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<label class="text-muted small mb-1">Store Credit Balance</label>
|
|
<h4 class="mb-0 @(Model.CreditBalance > 0 ? "text-success fw-bold" : "text-muted")">
|
|
@Model.CreditBalance.ToString("C")
|
|
</h4>
|
|
<small class="text-muted">Available for future invoices</small>
|
|
</div>
|
|
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
|
|
{
|
|
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#addCreditModal">
|
|
<i class="bi bi-plus-circle me-1"></i>Add Credit
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Outstanding Pickups -->
|
|
@{
|
|
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
|
|
}
|
|
@if (pendingPickups != null && pendingPickups.Count > 0)
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
|
|
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold text-warning-emphasis">
|
|
<i class="bi bi-truck me-2"></i>Ready for Pickup
|
|
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
@foreach (var pickup in pendingPickups)
|
|
{
|
|
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
|
|
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
|
|
<div class="flex-grow-1">
|
|
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
|
|
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
|
|
@if (!string.IsNullOrEmpty(pickup.Description))
|
|
{
|
|
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
|
|
}
|
|
</div>
|
|
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
|
|
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
|
|
</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Store Credit History -->
|
|
@{
|
|
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
|
}
|
|
@if (creditMemos != null && creditMemos.Count > 0)
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-credit-card me-2 text-primary"></i>Store Credit History
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-3">Memo #</th>
|
|
<th>Issued</th>
|
|
<th class="text-end">Amount</th>
|
|
<th class="text-end pe-3">Remaining</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var memo in creditMemos)
|
|
{
|
|
<tr>
|
|
<td class="ps-3">
|
|
<span class="fw-semibold small">@memo.MemoNumber</span>
|
|
<div class="text-muted" style="font-size:0.75rem;">@memo.Reason</div>
|
|
</td>
|
|
<td class="small text-muted align-middle">@memo.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</td>
|
|
<td class="text-end align-middle small">@memo.Amount.ToString("C")</td>
|
|
<td class="text-end pe-3 align-middle">
|
|
@if (memo.RemainingBalance > 0)
|
|
{
|
|
<span class="badge bg-success">@memo.RemainingBalance.ToString("C")</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary">Used</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Customer Stats -->
|
|
@{
|
|
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
|
|
}
|
|
@if (crmStats != null)
|
|
{
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Jobs row -->
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
|
|
<div class="text-muted small mb-1">Total Jobs</div>
|
|
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
|
|
@if (crmStats.ActiveJobs > 0)
|
|
{
|
|
<span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
|
|
@crmStats.ActiveJobs active
|
|
</span>
|
|
}
|
|
</div>
|
|
<div class="col-6 text-center p-2">
|
|
<div class="text-muted small mb-1">Avg Job Value</div>
|
|
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
|
|
</div>
|
|
</div>
|
|
<hr class="my-2" />
|
|
<!-- Revenue row -->
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-6">
|
|
<div class="text-muted small mb-1">Lifetime Revenue</div>
|
|
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="text-muted small mb-1">Total Collected</div>
|
|
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
|
|
</div>
|
|
</div>
|
|
<hr class="my-2" />
|
|
<!-- Footer stats -->
|
|
<div class="d-flex justify-content-between text-muted small mt-2">
|
|
<span>
|
|
@if (crmStats.DaysSinceLastJob.HasValue)
|
|
{
|
|
<i class="bi bi-calendar-check me-1"></i>
|
|
@if (crmStats.DaysSinceLastJob == 0)
|
|
{
|
|
<span>Last job today</span>
|
|
}
|
|
else
|
|
{
|
|
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<span>No jobs yet</span>
|
|
}
|
|
</span>
|
|
<span>
|
|
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
|
|
</span>
|
|
</div>
|
|
<div class="text-muted small mt-1">
|
|
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Preferred Powders -->
|
|
@{
|
|
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
|
|
}
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-droplet-fill me-2 text-primary"></i>Preferred Powders
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div id="preferred-powders-list">
|
|
@if (preferredPowders != null && preferredPowders.Count > 0)
|
|
{
|
|
@foreach (var p in preferredPowders)
|
|
{
|
|
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
|
|
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<span class="small fw-semibold">@p.InventoryItem.Name</span>
|
|
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
|
|
{
|
|
<span class="text-muted small ms-1">— @p.InventoryItem.ColorName</span>
|
|
}
|
|
@if (!string.IsNullOrEmpty(p.Notes))
|
|
{
|
|
<div class="text-muted" style="font-size:0.75rem;">@p.Notes</div>
|
|
}
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0"
|
|
onclick="removePreferredPowder(@Model.Id, @p.InventoryItemId)"
|
|
title="Remove from preferred">×</button>
|
|
</div>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
|
|
}
|
|
</div>
|
|
<div class="px-3 py-3 border-top bg-light position-relative">
|
|
<div class="mb-2">
|
|
<input type="text" id="powderSearchInput" class="form-control form-control-sm"
|
|
placeholder="Search powder by name or SKU..."
|
|
oninput="searchInventoryItems(this.value)"
|
|
autocomplete="off" />
|
|
<input type="hidden" id="selectedPowderId" />
|
|
<div id="powderSearchResults" class="dropdown-menu w-100 show p-0"
|
|
style="display:none!important;position:absolute;z-index:1000;"
|
|
onfocusout=""></div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<input type="text" id="powderNotes" class="form-control form-control-sm"
|
|
placeholder="Optional notes (e.g. "customer prefers this for wheels")"
|
|
maxlength="500" />
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-primary w-100"
|
|
onclick="addPreferredPowder(@Model.Id)">
|
|
<i class="bi bi-plus-circle me-1"></i>Add Powder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<style>
|
|
#powderSearchResults:not(:empty) { display:block!important; max-height:200px; overflow-y:auto; }
|
|
</style>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-header bg-white border-0 py-3">
|
|
<h5 class="mb-0 fw-semibold">
|
|
<i class="bi bi-lightning me-2 text-primary"></i>Quick Actions
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="d-grid gap-2">
|
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary">
|
|
<i class="bi bi-pencil me-2"></i>Edit Customer
|
|
</a>
|
|
<a asp-action="JobHistory" asp-route-id="@Model.Id" class="btn btn-outline-success">
|
|
<i class="bi bi-clock-history me-2"></i>Job History
|
|
</a>
|
|
<a asp-action="Invoices" asp-route-id="@Model.Id" class="btn btn-outline-warning">
|
|
<i class="bi bi-receipt me-2"></i>View Invoices
|
|
</a>
|
|
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
|
<i class="bi bi-journal-text me-2"></i>Statement
|
|
</a>
|
|
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
|
|
<i class="bi bi-plus-circle me-2"></i>New Job
|
|
</a>
|
|
@{
|
|
var crmStatsForActions = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
|
|
}
|
|
@if (crmStatsForActions?.LastJobId != null)
|
|
{
|
|
<a asp-controller="Jobs" asp-action="CloneJob" asp-route-id="@crmStatsForActions.LastJobId"
|
|
class="btn btn-outline-secondary"
|
|
title="Create a new job pre-filled with the last job's items and settings">
|
|
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
|
|
</a>
|
|
}
|
|
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
|
|
<i class="bi bi-file-text me-2"></i>New Quote
|
|
</a>
|
|
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
|
|
{
|
|
<button type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#addCreditModal">
|
|
<i class="bi bi-wallet2 me-2"></i>Add Store Credit
|
|
</button>
|
|
}
|
|
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
|
|
<i class="bi bi-trash me-2"></i>Delete Customer
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Store Credit Modal -->
|
|
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
|
|
{
|
|
<div class="modal fade" id="addCreditModal" tabindex="-1" aria-labelledby="addCreditModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form asp-action="AddCredit" asp-route-id="@Model.Id" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="addCreditModalLabel">
|
|
<i class="bi bi-wallet2 me-2 text-success"></i>Add Store Credit
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted small mb-3">
|
|
Credits can be applied to any future invoice for this customer.
|
|
Current balance: <strong class="text-success">@Model.CreditBalance.ToString("C")</strong>
|
|
</p>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
|
|
<div class="input-group">
|
|
<span class="input-group-text">$</span>
|
|
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01" max="99999.99" required placeholder="0.00" />
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
|
<select name="Reason" class="form-select" required id="creditReasonSelect">
|
|
<option value="">— Select reason —</option>
|
|
<option value="Pre-payment / Deposit">Pre-payment / Deposit</option>
|
|
<option value="Gift / Gift Card">Gift / Gift Card</option>
|
|
<option value="Overpayment credit">Overpayment credit</option>
|
|
<option value="Goodwill credit">Goodwill credit</option>
|
|
<option value="Other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Notes</label>
|
|
<textarea name="Notes" class="form-control" rows="2" maxlength="1000" placeholder="Optional details..."></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label fw-semibold">Expiry Date <span class="text-muted fw-normal">(optional)</span></label>
|
|
<input type="date" name="ExpiryDate" class="form-control" />
|
|
<div class="form-text">Leave blank for no expiry.</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-plus-circle me-1"></i>Add Credit
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@section Scripts {
|
|
<script src="~/js/customer-details.js" asp-append-version="true"></script>
|
|
}
|
|
|