Compare commits

..

7 Commits

Author SHA1 Message Date
spouliot cf6acc125f Complete mobile card view coverage for all remaining pages
- CSS fix: change blanket .table-responsive hide to only trigger when
  a .mobile-card-view sibling exists (.mobile-card-view ~ .table-responsive
  and :has() rule) — auto-fixes 60+ forms/reports/detail/help pages that
  were showing blank on mobile by making their tables scroll instead
- Add mobile card views to remaining list pages:
  JobsPriority (overdue jobs, main board, maintenance sections)
  NotificationLogs (email/SMS log entries)
  AiUsageReport (per-company AI usage breakdown)
  GiftCertificates/BulkResult (batch certificate list)
  Inventory/SamplePanels (Need to Order + On Wall tabs)
  BannedIps (active bans + lifted/expired bans)
  OnboardingProgress (per-company activation funnel)
  ReleaseNotes/Manage (versioned changelog entries)
  StorageMigration/Results (file migration status list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:31:38 -04:00
spouliot f467862877 Add mobile card views to 12 high-priority list pages
Pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px. Added .mobile-card-view sections to: GiftCertificates, PurchaseOrders,
CreditMemos, VendorCredits, JournalEntries, Appointments, InAppNotifications,
BankReconciliations, FixedAssets, RecurringTemplates, SmsAgreements, SmsConsentAudit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:52 -04:00
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
37 changed files with 13076 additions and 32 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>
} }
@@ -109,6 +109,69 @@
<span class="fw-semibold">Per-Company Breakdown</span> <span class="fw-semibold">Per-Company Breakdown</span>
<span class="text-muted small">@Model.Rows.Count companies total</span> <span class="text-muted small">@Model.Rows.Count companies total</span>
</div> </div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
<i class="bi bi-robot"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Today</span>
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">30 Days</span>
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">All Time</span>
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
@if (row.TopFeature != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Top Feature</span>
<span class="mobile-card-value">
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Tier</span>
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-building me-1"></i>Company
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
No AI usage logged yet.
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="aiUsageTable"> <table class="table table-hover mb-0 align-middle" id="aiUsageTable">
<thead class="table-light"> <thead class="table-light">
@@ -176,6 +176,60 @@
<div class="card-body"> <div class="card-body">
@if (Model.Items.Any()) @if (Model.Items.Any())
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var appointment in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-calendar-event"></i>
</div>
<div class="mobile-card-title">
<h6>@appointment.Title</h6>
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} &ndash; {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</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">
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
</span>
</div>
@if (!string.IsNullOrEmpty(appointment.CustomerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Customer</span>
<span class="mobile-card-value">@appointment.CustomerName</span>
</div>
}
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
@@ -21,6 +21,64 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var br in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
<i class="bi bi-bank"></i>
</div>
<div class="mobile-card-title">
<h6>@br.Account?.Name</h6>
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</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 (br.Status == BankReconciliationStatus.Completed)
{
<span class="badge bg-success">Completed</span>
}
else
{
<span class="badge bg-warning text-dark">In Progress</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Ending Balance</span>
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
</div>
@if (br.CompletedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Completed By</span>
<span class="mobile-card-value">@br.CompletedBy</span>
</div>
}
</div>
<div class="mobile-card-footer">
@if (br.Status == BankReconciliationStatus.Completed)
{
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Report
</a>
}
else
{
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-check2-square me-1"></i>Continue
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -60,6 +60,59 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (active.Any()) @if (active.Any())
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in active)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
<i class="bi bi-slash-circle"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value">
@if (ban.ExpiresAt.HasValue)
{
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
}
else
{
<span class="badge bg-secondary">Permanent</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-success">
<i class="bi bi-check-circle me-1"></i>Lift
</button>
</form>
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -130,6 +183,55 @@
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6> <h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in inactive)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
<i class="bi bi-clock-history"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small>
@if (!ban.IsActive)
{
<span class="badge bg-success">Lifted</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(ban.Reason))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Reason</span>
<span class="mobile-card-value text-muted">@ban.Reason</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0"> <table class="table table-sm table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -101,6 +101,73 @@
else else
{ {
<div class="card"> <div class="card">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var m in Model)
{
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
&& m.Status != CreditMemoStatus.FullyApplied
&& m.Status != CreditMemoStatus.Voided;
var (cmBadge, cmLabel) = m.Status switch
{
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
};
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
: m.Customer!.CompanyName;
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
<i class="bi bi-journal-minus"></i>
</div>
<div class="mobile-card-title">
<h6>@m.MemoNumber</h6>
<small>@cmCustomer</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"><span class="badge @cmBadge">@cmLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Amount</span>
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
@m.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
</div>
@if (m.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
@if (expired2) { <small>(Expired)</small> }
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
Details
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -118,6 +118,63 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var a in Model)
{
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
<i class="bi bi-building-gear"></i>
</div>
<div class="mobile-card-title">
<h6>@a.Name</h6>
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</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 (a.IsDisposed)
{
<span class="badge bg-secondary">Disposed</span>
}
else if (fd)
{
<span class="badge bg-light text-dark border">Fully Depreciated</span>
}
else
{
<span class="badge bg-success">Active</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Cost</span>
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Book Value</span>
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
@a.BookValue.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Monthly Depr.</span>
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -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,118 @@
@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="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@cert.OriginalAmount.ToString("C")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expiry</span>
<span class="mobile-card-value">
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-pdf me-1"></i>PDF
</a>
</div>
</div>
}
</div>
</div>
<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 -->
@@ -52,6 +57,73 @@ else
{ {
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
var (gcBadge, gcLabel) = cert.Status switch
{
GiftCertificateStatus.Active => ("bg-success", "Active"),
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", cert.Status.ToString())
};
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</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"><span class="badge @gcBadge">@gcLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Face Value</span>
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
@cert.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
</div>
@if (cert.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -75,6 +147,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 +163,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))
{ {
@@ -29,6 +29,61 @@
else else
{ {
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var n in items)
{
bool mIsRead = (bool)n.IsRead;
string mTitle = (string)n.Title;
string mMessage = (string)n.Message;
string? mLink = (string?)n.Link;
string mType = (string)n.NotificationType;
DateTime mCreatedAt = ((DateTime)n.CreatedAt).Tz(ViewBag.CompanyTimeZone as string);
<div class="mobile-data-card notif-history-row @(!mIsRead ? "notif-unread" : "")"
data-id="@n.Id"
data-title="@mTitle"
data-message="@mMessage"
data-link="@(mLink ?? "")"
data-type="@mType"
data-is-read="@(mIsRead ? "1" : "0")"
data-created-at="@mCreatedAt.ToString("MMM d, yyyy h:mm tt")">
<div class="mobile-card-header" style="@(!mIsRead ? "background:rgba(99,102,241,0.08);" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-bell"></i>
</div>
<div class="mobile-card-title">
<h6 class="@(!mIsRead ? "fw-semibold" : "text-muted")">
@if (!mIsRead)
{
<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;"></span>
}
@mTitle
</h6>
<small>@mCreatedAt.ToString("MMM d, yyyy h:mm tt")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value"><span class="badge bg-secondary bg-opacity-25 text-body small">@mType</span></span>
</div>
<div class="mobile-card-row" style="align-items:flex-start;">
<span class="mobile-card-label">Message</span>
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@mMessage</span>
</div>
</div>
@if (!string.IsNullOrEmpty(mLink))
{
<div class="mobile-card-footer">
<a href="@mLink" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-arrow-right me-1"></i>Open
</a>
</div>
}
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -126,6 +126,77 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@{ lastMfr = null; }
@foreach (var item in needOrder)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</div>
}
<div class="mobile-data-card">
<div class="mobile-card-header">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
}
else
{
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #64748b 0%, #475569 100%);">
<i class="bi bi-palette"></i>
</div>
}
<div class="mobile-card-title">
<h6>@(item.ColorName ?? item.Name)</h6>
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Part #</span>
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(item.Finish))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Finish</span>
<span class="mobile-card-value">@item.Finish</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">In Stock</span>
<span class="mobile-card-value">
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success bg-opacity-10 text-success">@item.QuantityOnHand.ToString("N2") @item.UnitOfMeasure</span>
}
else
{
<span class="text-muted">None</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-success btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="true">
<i class="bi bi-check-lg me-1"></i>Got It
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0" id="needTable"> <table class="table table-hover mb-0" id="needTable">
<thead class="table-group-divider"> <thead class="table-group-divider">
@@ -220,6 +291,68 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@{ lastMfr = null; }
@foreach (var item in onHand)
{
if (string.IsNullOrWhiteSpace(selectedMfr) && item.Manufacturer != lastMfr)
{
lastMfr = item.Manufacturer;
<div class="text-uppercase fw-semibold text-muted small px-2 py-1 border-bottom mt-2">
@(string.IsNullOrWhiteSpace(item.Manufacturer) ? "No Manufacturer" : item.Manufacturer)
</div>
}
<div class="mobile-data-card">
<div class="mobile-card-header">
@if (!string.IsNullOrWhiteSpace(item.ColorCode))
{
<div class="mobile-card-icon" style="background: @(item.ColorCode.StartsWith("#") ? item.ColorCode : "#" + item.ColorCode); border: 1px solid var(--bs-border-color);"></div>
}
else
{
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #059669 0%, #047857 100%);">
<i class="bi bi-palette"></i>
</div>
}
<div class="mobile-card-title">
<h6>@(item.ColorName ?? item.Name)</h6>
<small>@(item.Manufacturer ?? "No Manufacturer")</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrWhiteSpace(item.ManufacturerPartNumber))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Part #</span>
<span class="mobile-card-value text-muted">@item.ManufacturerPartNumber</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(item.Finish))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Finish</span>
<span class="mobile-card-value">@item.Finish</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>On Wall</span></span>
</div>
</div>
<div class="mobile-card-footer">
<button class="btn btn-sm btn-outline-danger btn-toggle-panel"
data-item-id="@item.Id" data-has-panel="false">
<i class="bi bi-x-lg me-1"></i>Remove
</button>
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-group-divider"> <thead class="table-group-divider">
@@ -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>
@@ -71,6 +71,59 @@
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var job in overdueJobs)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
<i class="bi bi-exclamation-triangle"></i>
</div>
<div class="mobile-card-title">
<h6>@job.JobNumber</h6>
<small>@job.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"><span class="badge bg-@job.StatusColorClass">@job.StatusDisplayName</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass">@job.PriorityDisplayName</span></span>
</div>
@if (job.ScheduledDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Scheduled</span>
<span class="mobile-card-value text-danger fw-bold">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (job.DueDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value text-danger">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')">
<i class="bi bi-flag"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -191,6 +244,74 @@
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var job in Model)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Jobs", new { id = job.JobId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);">
<i class="bi bi-kanban"></i>
</div>
<div class="mobile-card-title">
<h6>@job.JobNumber</h6>
<small>@job.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"><span class="badge bg-@job.StatusColorClass" id="status-badge-@job.JobId">@job.StatusDisplayName</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@job.PriorityColorClass priority-badge-@job.JobId">@job.PriorityDisplayName</span></span>
</div>
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value"><span class="badge bg-info"><i class="bi bi-person me-1"></i>@job.AssignedWorkerName</span></span>
</div>
}
@if (job.ScheduledDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Scheduled</span>
<span class="mobile-card-value">@job.ScheduledDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (job.DueDate.HasValue)
{
var mJobOverdue = job.DueDate.Value.Date < DateTime.Today;
<div class="mobile-card-row">
<span class="mobile-card-label">Due</span>
<span class="mobile-card-value @(mJobOverdue ? "text-danger fw-bold" : "")">@job.DueDate.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@job.JobId" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openPriorityModal(@job.JobId, @job.JobPriorityId, '@job.JobNumber')" title="Change Priority">
<i class="bi bi-flag"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openWorkerModal(@job.JobId, '@(job.AssignedUserId ?? "")', '@job.JobNumber')" title="Assign Worker">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-check fs-1 d-block mb-2 opacity-25"></i>
No jobs scheduled for @scheduledDate.ToString("MMMM dd, yyyy").
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0" id="jobsTable"> <table class="table table-hover mb-0" id="jobsTable">
<thead> <thead>
@@ -352,6 +473,65 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in maintenanceItems)
{
var mPriorityBg = item.Priority switch
{
MaintenancePriority.Critical => "danger",
MaintenancePriority.High => "warning",
MaintenancePriority.Normal => "info",
_ => "secondary"
};
var mStatusBgM = item.Status == MaintenanceStatus.InProgress ? "success" : "primary";
var mStatusLbl = item.Status == MaintenanceStatus.InProgress ? "In Progress" : "Scheduled";
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Maintenance", new { id = item.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);">
<i class="bi bi-tools"></i>
</div>
<div class="mobile-card-title">
<h6>@(item.Equipment?.EquipmentName ?? "Maintenance")</h6>
<small>@item.MaintenanceType</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value"><span class="badge bg-@mPriorityBg">@item.Priority</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-@mStatusBgM">@mStatusLbl</span></span>
</div>
@if (item.AssignedUser != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value"><span class="badge bg-info text-dark"><i class="bi bi-person me-1"></i>@item.AssignedUser.FullName</span></span>
</div>
}
@if (!string.IsNullOrEmpty(item.Description))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Desc.</span>
<span class="mobile-card-value text-muted">@item.Description</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Maintenance" asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation(); openMaintenanceWorkerModal(@item.Id, '@(item.AssignedUserId ?? "")', '@(item.Equipment?.EquipmentName ?? "Maintenance")')" title="Assign Worker">
<i class="bi bi-person"></i>
</button>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead> <thead>
@@ -47,6 +47,68 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var je in Model)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = je.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);">
<i class="bi bi-journal-text"></i>
</div>
<div class="mobile-card-title">
<h6>
@je.EntryNumber
@if (je.IsReversal)
{
<span class="badge bg-secondary ms-1">REV</span>
}
</h6>
<small>@je.EntryDate.ToString("MMM d, yyyy")</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 (je.Status == JournalEntryStatus.Draft)
{
<span class="badge bg-warning text-dark">Draft</span>
}
else if (je.Status == JournalEntryStatus.Posted)
{
<span class="badge bg-success">Posted</span>
}
else
{
<span class="badge bg-secondary">Reversed</span>
}
</span>
</div>
@if (!string.IsNullOrWhiteSpace(je.Description))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Description</span>
<span class="mobile-card-value" style="white-space:normal;text-align:right;">@je.Description</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(je.Reference))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Reference</span>
<span class="mobile-card-value">@je.Reference</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@je.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
+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>
@@ -134,6 +134,67 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var item in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)" : "linear-gradient(135deg, #06b6d4 0%, #0e7490 100%)");">
<i class="bi @(item.Channel == PowderCoating.Core.Enums.NotificationChannel.Email ? "bi-envelope" : "bi-phone")"></i>
</div>
<div class="mobile-card-title">
<h6>@item.RecipientName</h6>
<small>@item.Recipient</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">@item.NotificationTypeDisplay</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Sent</span>
<span class="mobile-card-value">@item.SentAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd HH:mm")</span>
</div>
@if (item.JobId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Job</span>
<span class="mobile-card-value">@item.JobNumber</span>
</div>
}
else if (item.QuoteId.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Quote</span>
<span class="mobile-card-value">@item.QuoteNumber</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@{
var (mStatusBadge, mStatusIcon) = item.Status switch
{
PowderCoating.Core.Enums.NotificationStatus.Sent => ("bg-success", "bi-check-circle"),
PowderCoating.Core.Enums.NotificationStatus.Failed => ("bg-danger", "bi-x-circle"),
_ => ("bg-secondary", "bi-dash-circle")
};
}
<span class="badge @mStatusBadge"><i class="bi @mStatusIcon me-1"></i>@item.StatusDisplay</span>
</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@item.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -44,6 +44,91 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
var oPct = row.TotalSteps == 0 ? 0 : row.StepsCompleted * 100 / row.TotalSteps;
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Companies", new { id = row.CompanyId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-building"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CompanyName</h6>
<small>
@switch (row.Status)
{
case OnboardingStatus.Complete:
<span class="badge bg-success">Complete</span>
break;
case OnboardingStatus.InProgress:
<span class="badge bg-warning text-dark">In Progress</span>
break;
case OnboardingStatus.Dismissed:
<span class="badge bg-secondary">Dismissed</span>
break;
default:
<span class="badge bg-light text-muted border">Not Started</span>
break;
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Wizard</span>
<span class="mobile-card-value">
@if (row.WizardCompleted)
{
<i class="bi bi-check-circle-fill text-success"></i>
<span class="text-success ms-1">Done</span>
}
else
{
<i class="bi bi-circle text-muted"></i>
<span class="text-muted ms-1">Pending</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Milestones</span>
<span class="mobile-card-value">
<div class="d-flex align-items-center gap-2">
<div class="progress" style="height:5px; width:60px;">
<div class="progress-bar @(oPct == 100 ? "bg-success" : "bg-primary")" style="width:@oPct%"></div>
</div>
<small class="text-muted">@row.StepsCompleted/@row.TotalSteps</small>
</div>
</span>
</div>
@{
var oFirstActivity = row.FirstJobCreatedAt ?? row.FirstQuoteCreatedAt;
}
@if (oFirstActivity.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">First Activity</span>
<span class="mobile-card-value text-muted">@oFirstActivity.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-building me-1"></i>View
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-building fs-1 d-block mb-2 opacity-25"></i>
No companies found.
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="onboardingTable"> <table class="table table-hover align-middle mb-0" id="onboardingTable">
<thead class="table-light"> <thead class="table-light">
@@ -164,6 +164,56 @@
<!-- Grid --> <!-- Grid -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var po in Model.Items)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = po.Id })'">
<div class="mobile-card-header" style="@(po.IsOverdue ? "background:#fee2e2;" : "")">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
<i class="bi bi-cart-check"></i>
</div>
<div class="mobile-card-title">
<h6>@po.PoNumber @(po.IsOverdue ? " — Overdue" : "")</h6>
<small>@po.VendorName</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"><span class="badge bg-@StatusBadge(po.Status)">@po.Status</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Order Date</span>
<span class="mobile-card-value">@po.OrderDate.ToString("MM/dd/yy")</span>
</div>
@if (po.ExpectedDeliveryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expected</span>
<span class="mobile-card-value @(po.IsOverdue ? "text-danger fw-semibold" : "")">
@po.ExpectedDeliveryDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Items</span>
<span class="mobile-card-value">@po.ItemCount</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value fw-semibold">$@po.TotalAmount.ToString("N2")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@po.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -38,6 +38,96 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var t in Model)
{
var isOverdueRT = t.IsActive && t.NextFireDate.Date < DateTime.Today;
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);">
<i class="bi bi-arrow-repeat"></i>
</div>
<div class="mobile-card-title">
<h6>@t.Name</h6>
<small>
@if (t.TemplateType == RecurringTemplateType.Bill)
{
<span>Bill</span>
}
else
{
<span>Expense</span>
}
&mdash;
@(t.IntervalCount == 1 ? t.Frequency.ToString() : $"Every {t.IntervalCount} &times; {t.Frequency}")
</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 (t.IsActive)
{
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
}
else
{
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
}
</span>
</div>
@if (t.IsActive)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Next Fire</span>
<span class="mobile-card-value @(isOverdueRT ? "text-danger fw-semibold" : "")">
@t.NextFireDate.ToString("MM/dd/yyyy")
@if (isOverdueRT) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Occurrences</span>
<span class="mobile-card-value">
@t.OccurrenceCount
@if (t.MaxOccurrences.HasValue) { <span class="text-muted"> / @t.MaxOccurrences</span> }
</span>
</div>
@if (!string.IsNullOrWhiteSpace(t.LastError))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Error</span>
<span class="mobile-card-value text-danger small">@t.LastError</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post" style="display:inline;">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")">
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
</button>
</form>
@if (t.IsActive)
{
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post" style="display:inline;"
onsubmit="return confirm('Generate one occurrence now?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-lightning-charge"></i>
</button>
</form>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead class="table-light"> <thead class="table-light">
@@ -65,6 +65,77 @@
</div> </div>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var note in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%);">
<i class="bi bi-journal-text"></i>
</div>
<div class="mobile-card-title">
<h6><code>v@(note.Version)</code> &mdash; @note.Title</h6>
<small>
<span class="badge @TagBadge(note.Tag)">@note.Tag</span>
&nbsp;
@if (note.IsPublished)
{
<span class="badge bg-success">Published</span>
}
else
{
<span class="badge bg-warning text-dark">Draft</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Released</span>
<span class="mobile-card-value text-muted">@note.ReleasedAt.ToString("MM/dd/yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Created By</span>
<span class="mobile-card-value text-muted">@note.CreatedByUserName</span>
</div>
@if (note.Body.Length > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Preview</span>
<span class="mobile-card-value text-muted">@(note.Body.Length > 60 ? note.Body[..60] + "…" : note.Body)</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Edit" asp-route-id="@note.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="TogglePublish" asp-route-id="@note.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(note.IsPublished ? "btn-outline-warning" : "btn-outline-success")">
<i class="bi @(note.IsPublished ? "bi-eye-slash" : "bi-eye")"></i>
</button>
</form>
<form asp-action="Delete" asp-route-id="@note.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete v@(note.Version)?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
@if (!Model.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-journal-x fs-1 d-block mb-2 opacity-25"></i>
No release notes yet. <a asp-action="Create">Create the first one.</a>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0 small"> <table class="table table-hover align-middle mb-0 small">
<thead class="table-light"> <thead class="table-light">
@@ -112,6 +112,101 @@
} }
else else
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-building"></i>
</div>
<div class="mobile-card-title">
<h6>
@row.CompanyName
@if (row.IsDeleted) { <span class="badge bg-secondary ms-1">Deleted</span> }
</h6>
<small>
@if (row.SmsDisabledByAdmin)
{
<span class="text-danger"><i class="bi bi-slash-circle me-1"></i>Admin-Disabled</span>
}
else if (row.SmsEnabled)
{
<span class="text-success"><i class="bi bi-chat-dots me-1"></i>SMS Enabled</span>
}
else
{
<span class="text-muted">SMS Off</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Terms</span>
<span class="mobile-card-value">
@{
var dispAgreement = row.CurrentAgreement ?? row.LatestAgreement;
}
@if (row.CurrentAgreement != null)
{
<span class="badge bg-success">v@(row.CurrentAgreement.TermsVersion)</span>
}
else if (row.LatestAgreement != null)
{
<span class="badge bg-warning text-dark">Stale (v@(row.LatestAgreement.TermsVersion))</span>
}
else
{
<span class="badge bg-light text-muted border">Never</span>
}
</span>
</div>
@if (dispAgreement != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Accepted By</span>
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
@dispAgreement.AgreedByUserName
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Accepted At</span>
<span class="mobile-card-value @(row.CurrentAgreement == null ? "text-muted" : "")">
@dispAgreement.AgreedAt.ToString("MM/dd/yy")
</span>
</div>
}
@if (row.AllAgreements.Count > 0)
{
<div class="mobile-card-row">
<span class="mobile-card-label">History</span>
<span class="mobile-card-value">
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#historyModal"
data-company="@row.CompanyName"
data-history="@System.Text.Json.JsonSerializer.Serialize(row.AllAgreements.Select(a => new {
a.TermsVersion,
a.AgreedByUserName,
a.AgreedByUserId,
AgreedAt = a.AgreedAt.ToString("MMM d, yyyy 'at' h:mm tt") + " UTC",
IpAddress = a.IpAddress ?? "—",
UserAgent = a.UserAgent ?? "—"
}), new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase })"
onclick="event.stopPropagation()">
@row.AllAgreements.Count <i class="bi bi-clock-history ms-1"></i>
</button>
</span>
</div>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -110,6 +110,64 @@
</form> </form>
</div> </div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", "Customers", new { id = row.CustomerId })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #ec4899 0%, #be185d 100%);">
<i class="bi bi-phone-vibrate"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CustomerName</h6>
<small>@(row.MobilePhone ?? row.Phone ?? "No phone")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">SMS Status</span>
<span class="mobile-card-value"><span class="badge @row.StatusBadgeClass">@row.StatusLabel</span></span>
</div>
@if (row.ConsentedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Consented</span>
<span class="mobile-card-value">@row.ConsentedAt.Value.ToString("MMM d, yyyy")</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(row.ConsentMethod))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Method</span>
<span class="mobile-card-value">@row.ConsentMethod</span>
</div>
}
@if (row.OptedOutAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Opted Out</span>
<span class="mobile-card-value text-danger">@row.OptedOutAt.Value.ToString("MMM d, yyyy")</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-controller="Customers" asp-action="Details" asp-route-id="@row.CustomerId"
class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-person me-1"></i>Customer
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-phone-vibrate fs-1 d-block mb-2 opacity-25"></i>
No customers match the current filter.
</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">
@@ -84,6 +84,46 @@
} }
</div> </div>
</div> </div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var file in Model.Files.OrderBy(f => f.Status).ThenBy(f => f.RelativePath))
{
var fStatusBadge = file.Status switch
{
MigrationFileStatus.Migrated => "bg-success",
MigrationFileStatus.Skipped => "bg-secondary",
_ => "bg-danger"
};
var fStatusLabel = file.Status switch
{
MigrationFileStatus.Migrated => "Migrated",
MigrationFileStatus.Skipped => "Already in Azure",
_ => "Failed"
};
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0369a1 0%, #075985 100%);">
<i class="bi bi-file-earmark"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace" style="font-size:.75rem;">@file.RelativePath</h6>
<small><span class="badge bg-light text-dark border">@file.Container</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Size</span>
<span class="mobile-card-value text-muted">@FormatBytes(file.FileSize)</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge @fStatusBadge">@fStatusLabel</span></span>
</div>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0"> <table class="table table-sm table-hover mb-0">
<thead> <thead>
@@ -68,6 +68,64 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var vc in Model)
{
var (vcBadge, vcLabel) = vc.Status switch
{
VendorCreditStatus.Open => ("bg-success", "Open"),
VendorCreditStatus.PartiallyApplied => ("bg-warning text-dark", "Partial"),
VendorCreditStatus.Applied => ("bg-secondary", "Applied"),
VendorCreditStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", vc.Status.ToString())
};
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = vc.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<i class="bi bi-credit-card"></i>
</div>
<div class="mobile-card-title">
<h6>@vc.CreditNumber</h6>
<small>@vc.Vendor?.CompanyName</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"><span class="badge @vcBadge">@vcLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Date</span>
<span class="mobile-card-value">@vc.CreditDate.ToString("MM/dd/yy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Total</span>
<span class="mobile-card-value">@vc.Total.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(vc.RemainingAmount > 0 ? "text-success fw-semibold" : "text-muted")">
@(vc.RemainingAmount > 0 ? vc.RemainingAmount.ToString("C") : "&mdash;")
</span>
</div>
@if (!string.IsNullOrWhiteSpace(vc.Memo))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Memo</span>
<span class="mobile-card-value">@vc.Memo</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@vc.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -9,8 +9,9 @@
} }
@media (max-width: 991px) { @media (max-width: 991px) {
/* Hide desktop table view on mobile */ /* Hide desktop table only when a mobile card view sibling is present */
.table-responsive { .mobile-card-view ~ .table-responsive,
.table-responsive:has(~ .mobile-card-view) {
display: none !important; display: none !important;
} }
@@ -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...';
});
}
}());