Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Companies/Index.cshtml
T
spouliot 03d3f57f7b Fix time entry workers, powder usage logging, inventory edit, and mojibake
- JobTimeEntry: migrate to UserId/UserDisplayName; make ShopWorkerId nullable
  (migration MigrateTimeEntriesToUserId)
- Log Time modal: populate worker dropdown from Identity users instead of
  ShopWorkers; fix ShopMobile view same issue
- Inventory Ledger: scan-based JobUsage transactions now appear in
  Powder Usage By Job tab (synthesized from InventoryTransaction)
- Inventory Ledger: add Edit button for JobUsage transactions; new
  GetUsageForEdit + EditUsageTransaction endpoints; inventory-ledger.js
- InventoryTransactionRepository: include Job.Customer for ledger queries
- InventoryAiLookupService: handle JSON-LD @graph wrapper (Columbia
  Coatings / WooCommerce+Yoast); add HTML price snippet fallback
- Fix mojibake in 9 views: â†' → →, âœ" → ✓, âš  → ⚠

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:05:37 -04:00

482 lines
27 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@model List<PowderCoating.Application.DTOs.Company.CompanyListDto>
@section Styles {
<style>
[data-bs-theme="dark"] a.text-dark { color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .bi-building[style*="color:#ccc"] { color: var(--bs-secondary-color) !important; }
</style>
}
@{
ViewData["Title"] = "Companies";
ViewData["PageIcon"] = "bi-building";
var planConfigs = ((IEnumerable<PowderCoating.Core.Entities.SubscriptionPlanConfig>)ViewBag.PlanConfigs)
.OrderBy(c => c.SortOrder).ToList();
var planBadgeColors = planConfigs.Select((c, i) => (c.Plan, i))
.ToDictionary(x => x.Plan, x => x.i switch { 0 => "bg-secondary", 1 => "bg-primary", 2 => "bg-info", _ => "bg-success" });
var planNames = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanBadge(int plan) => planBadgeColors.TryGetValue(plan, out var c) ? c : "bg-secondary";
string PlanName(int plan) => planNames.TryGetValue(plan, out var n) ? n : plan.ToString();
var searchTerm = (string?)ViewBag.SearchTerm;
var sortColumn = (string)(ViewBag.SortColumn ?? "CompanyName");
var sortDirection = (string)(ViewBag.SortDirection ?? "asc");
var pageNumber = (int)(ViewBag.PageNumber ?? 1);
var pageSize = (int)(ViewBag.PageSize ?? 25);
var totalPages = (int)(ViewBag.TotalPages ?? 1);
var totalCount = (int)(ViewBag.TotalCount ?? 0);
var impersonatingId = (int?)(ViewBag.ImpersonatingCompanyId);
string SortLink(string col)
{
var dir = (sortColumn == col && sortDirection == "asc") ? "desc" : "asc";
return Url.Action("Index", new { searchTerm, sortColumn = col, sortDirection = dir, pageNumber = 1, pageSize })!;
}
string SortIcon(string col)
{
if (sortColumn != col) return "bi-chevron-expand text-muted";
return sortDirection == "asc" ? "bi-chevron-up" : "bi-chevron-down";
}
}
<div class="container-fluid">
<div class="d-flex justify-content-end mb-4">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Create New Company
</a>
</div>
<!-- Search -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form asp-action="Index" method="get" class="row g-2 align-items-end">
<input type="hidden" name="sortColumn" value="@sortColumn" />
<input type="hidden" name="sortDirection" value="@sortDirection" />
<input type="hidden" name="pageSize" value="@pageSize" />
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="searchTerm" class="form-control"
placeholder="Search by name, code, email, phone…"
value="@searchTerm" />
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
@if (!string.IsNullOrWhiteSpace(searchTerm))
{
<a asp-action="Index" asp-route-sortColumn="@sortColumn"
asp-route-sortDirection="@sortDirection" asp-route-pageSize="@pageSize"
class="btn btn-outline-secondary ms-1">Clear</a>
}
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
@if (Model != null && Model.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>
<a href="@SortLink("CompanyName")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Company Name <i class="bi @SortIcon("CompanyName")"></i>
</a>
</th>
<th>Code</th>
<th>Contact Email</th>
<th>Phone</th>
<th>
<a href="@SortLink("Plan")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Plan <i class="bi @SortIcon("Plan")"></i>
</a>
</th>
<th>Users</th>
<th>Setup Wizard</th>
<th>
<a href="@SortLink("Status")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Status <i class="bi @SortIcon("Status")"></i>
</a>
</th>
<th>
<a href="@SortLink("Created")" class="text-decoration-none text-dark d-flex align-items-center gap-1">
Created <i class="bi @SortIcon("Created")"></i>
</a>
</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var company in Model)
{
var isImpersonating = impersonatingId.HasValue && impersonatingId.Value == company.Id;
var detailsUrl = Url.Action("Details", new { id = company.Id });
<tr class="@(isImpersonating ? "table-warning" : "")" style="cursor:pointer"
onclick="window.location='@detailsUrl'">
<td>
<strong>@company.CompanyName</strong>
@if (isImpersonating)
{
<span class="badge bg-warning text-dark ms-1">
<i class="bi bi-eye-fill me-1"></i>Active
</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(company.CompanyCode))
{
<span class="badge bg-secondary">@company.CompanyCode</span>
}
</td>
<td>@company.PrimaryContactEmail</td>
<td>@company.Phone</td>
<td>
<span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span>
</td>
<td>
<span class="badge bg-primary rounded-pill">@company.UserCount</span>
</td>
<td>
@if (company.WizardCompleted)
{
var tooltip = company.WizardCompletedByName != null
? $"Completed by {company.WizardCompletedByName}"
+ (company.WizardCompletedAt.HasValue
? $" on {company.WizardCompletedAt.Value.Tz(ViewBag.CompanyTimeZone as string):MMM d, yyyy}"
: "")
: "Completed";
<span class="badge bg-success" title="@tooltip" data-bs-toggle="tooltip">
<i class="bi bi-check-circle-fill me-1"></i>Done
</span>
}
else
{
<span class="badge bg-light text-muted border">
<i class="bi bi-hourglass me-1"></i>Pending
</span>
}
</td>
<td>
@if (company.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
<small class="text-muted">@company.CreatedAt.ToString("MMM d, yyyy")</small>
</td>
<td class="text-end" onclick="event.stopPropagation()">
<div class="btn-group btn-group-sm" role="group">
<a asp-action="Details" asp-route-id="@company.Id"
class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@company.Id"
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@company.Id"
class="btn btn-outline-info" title="Manage Subscription & Features">
<i class="bi bi-credit-card"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@company.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit"
class="btn @(company.IsActive ? "btn-outline-warning" : "btn-outline-success")"
title="@(company.IsActive ? "Deactivate" : "Activate")">
<i class="bi bi-@(company.IsActive ? "pause" : "play")"></i>
</button>
</form>
@if (isImpersonating)
{
<form asp-action="StopImpersonating" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning" title="Stop Impersonating">
<i class="bi bi-x-circle"></i>
</button>
</form>
}
else
{
<form asp-action="StartImpersonating" method="post" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="companyId" value="@company.Id" />
<button type="submit" class="btn btn-outline-dark" title="Impersonate this company">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
}
<button type="button"
class="btn btn-outline-danger"
title="Delete Company"
onclick="event.stopPropagation(); openDeleteModal(@company.Id, '@Html.Raw(company.CompanyName.Replace("'", "\\'"))', @company.UserCount, @company.JobCount, @company.QuoteCount, @company.CustomerCount)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var company in Model)
{
var isImpersonating = impersonatingId.HasValue && impersonatingId.Value == company.Id;
<a href="@Url.Action("Details", new { id = company.Id })" class="mobile-data-card text-decoration-none">
<div class="mobile-card-header">
<div class="mobile-card-icon bg-primary"><i class="bi bi-building"></i></div>
<div class="mobile-card-title">
<h6>@company.CompanyName</h6>
<small class="text-muted">@company.PrimaryContactEmail</small>
</div>
@if (company.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Plan</span>
<span class="mobile-card-value"><span class="badge @PlanBadge(company.SubscriptionPlan)">@PlanName(company.SubscriptionPlan)</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Users</span>
<span class="mobile-card-value"><span class="badge bg-primary rounded-pill">@company.UserCount</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Created</span>
<span class="mobile-card-value">@company.CreatedAt.ToString("MMM d, yyyy")</span>
</div>
@if (!string.IsNullOrEmpty(company.CompanyCode))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Code</span>
<span class="mobile-card-value"><span class="badge bg-secondary">@company.CompanyCode</span></span>
</div>
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
</div>
</a>
}
</div>
</div>
<!-- Pagination -->
@if (totalPages > 1)
{
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small">
Showing @((pageNumber - 1) * pageSize + 1)@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
</div>
<div class="d-flex align-items-center gap-3">
<div>
<select class="form-select form-select-sm" onchange="changePageSize(this.value)">
@foreach (var size in new[] { 10, 25, 50, 100 })
{
<option value="@size" selected="@(pageSize == size)">@size per page</option>
}
</select>
</div>
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(pageNumber == 1 ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber - 1, pageSize })">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@for (int p = Math.Max(1, pageNumber - 2); p <= Math.Min(totalPages, pageNumber + 2); p++)
{
<li class="page-item @(p == pageNumber ? "active" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = p, pageSize })">@p</a>
</li>
}
<li class="page-item @(pageNumber == totalPages ? "disabled" : "")">
<a class="page-link" href="@Url.Action("Index", new { searchTerm, sortColumn, sortDirection, pageNumber = pageNumber + 1, pageSize })">
<i class="bi bi-chevron-right"></i>
</a>
</li>
</ul>
</nav>
</div>
</div>
}
}
else
{
<div class="text-center py-5">
<i class="bi bi-building text-secondary" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">
@if (!string.IsNullOrWhiteSpace(searchTerm))
{
<text>No companies match "<strong>@searchTerm</strong>".</text>
}
else
{
<text>No companies found.</text>
}
</p>
@if (string.IsNullOrWhiteSpace(searchTerm))
{
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Create Your First Company
</a>
}
</div>
}
</div>
</div>
</div>
<!-- Delete Company Modal -->
<div class="modal fade" id="deleteCompanyModal" tabindex="-1" aria-labelledby="deleteCompanyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteCompanyModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Delete Company
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<p class="mb-3">
You are about to delete <strong id="modal-company-name"></strong>. This company has the following data:
</p>
<div class="row g-3 mb-4">
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-primary" id="modal-user-count">0</div>
<small class="text-muted">Users</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-success" id="modal-job-count">0</div>
<small class="text-muted">Jobs</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-info" id="modal-quote-count">0</div>
<small class="text-muted">Quotes</small>
</div>
</div>
<div class="col-3">
<div class="card border text-center p-2">
<div class="fs-4 fw-bold text-warning" id="modal-customer-count">0</div>
<small class="text-muted">Customers</small>
</div>
</div>
</div>
<hr />
<!-- Soft Delete Section -->
<div class="mb-4">
<h6 class="fw-bold text-warning"><i class="bi bi-pause-circle me-2"></i>Soft Delete (Deactivate)</h6>
<p class="text-muted small mb-2">
The company and all users will be <strong>deactivated</strong> but data is preserved.
This is reversible and can be undone by an administrator.
</p>
<form id="softDeleteForm" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="soft-delete-id" />
<button type="submit" class="btn btn-warning">
<i class="bi bi-pause-circle me-1"></i>Deactivate Company
</button>
</form>
</div>
<hr />
<!-- Hard Delete Section -->
<div>
<h6 class="fw-bold text-danger"><i class="bi bi-fire me-2"></i>Hard Delete (Permanent)</h6>
<p class="text-muted small mb-2">
<strong class="text-danger">This cannot be undone.</strong>
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
<strong>permanently and irreversibly deleted</strong> from the database.
</p>
<div class="alert alert-danger py-2 mb-3">
<i class="bi bi-exclamation-octagon-fill me-2"></i>
Type <strong>DELETE</strong> below to enable permanent deletion.
</div>
<div class="mb-3">
<input type="text"
id="hard-delete-confirmation"
class="form-control"
placeholder="Type DELETE to confirm"
autocomplete="off"
oninput="validateHardDelete(this.value)" />
</div>
<form id="hardDeleteForm" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" id="hard-delete-id" />
<input type="hidden" name="confirmation" value="DELETE" />
<button type="submit" id="hard-delete-btn" class="btn btn-danger" disabled>
<i class="bi bi-trash-fill me-1"></i>Permanently Delete Everything
</button>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function changePageSize(size) {
const url = new URL(window.location.href);
url.searchParams.set('pageSize', size);
url.searchParams.set('pageNumber', '1');
window.location.href = url.toString();
}
function openDeleteModal(id, name, users, jobs, quotes, customers) {
document.getElementById('modal-company-name').textContent = name;
document.getElementById('modal-user-count').textContent = users;
document.getElementById('modal-job-count').textContent = jobs;
document.getElementById('modal-quote-count').textContent = quotes;
document.getElementById('modal-customer-count').textContent = customers;
document.getElementById('soft-delete-id').value = id;
document.getElementById('hard-delete-id').value = id;
document.getElementById('softDeleteForm').action = '/Companies/SoftDelete/' + id;
document.getElementById('hardDeleteForm').action = '/Companies/HardDelete/' + id;
// Reset hard delete input
document.getElementById('hard-delete-confirmation').value = '';
document.getElementById('hard-delete-btn').disabled = true;
new bootstrap.Modal(document.getElementById('deleteCompanyModal')).show();
}
function validateHardDelete(value) {
document.getElementById('hard-delete-btn').disabled = (value !== 'DELETE');
}
</script>
}