Files
PowderCoatingLogix/src/PowderCoating.Web/Views/Jobs/Index.cshtml
T
spouliot 24f3df1bbc Jobs list defaults to On Floor; add Completed filter pill; fix encoding bugs
- /Jobs now redirects to ?statusGroup=active so completed jobs don't clutter the default view
- Add Completed pill (filters Completed + ReadyForPickup + Delivered)
- Pill badge counts are now global DB counts, not page-local item counts
- Ready pill badge now shows ReadyForPickup-only count
- All pill links to ?statusGroup=all to bypass the redirect
- Fix double-encoded & in Completed filter alert label
- Fix corrupted em dash (â€") in Customers/Details billing email fallback text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:11:33 -04:00

1009 lines
54 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 PagedResult<PowderCoating.Application.DTOs.Job.JobListDto>
@{
ViewData["Title"] = "Jobs";
ViewData["PageIcon"] = "bi-briefcase";
ViewData["PageHelpTitle"] = "Jobs";
ViewData["PageHelpContent"] = "A Job is the active work order once a customer's quote is approved. Jobs track the full lifecycle from intake through coating, curing, quality check, and delivery. Each job can contain multiple items with individual coating specifications. Priority and due date help the shop floor triage workload.";
}
@{
var _allCount = (int)(ViewBag.AllJobCount ?? 0);
var _wip = (int)(ViewBag.ActiveCount ?? 0);
var _done = (int)(ViewBag.CompletedCount ?? 0);
var _ready = (int)(ViewBag.ReadyCount ?? 0);
var _overdue = (int)(ViewBag.OverdueCount ?? 0);
var _value = Model.Items.Sum(j => j.FinalPrice);
}
<div class="pcl-metric-strip">
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "TOTAL", Value: Model.TotalCount.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "IN PROGRESS", Value: _wip.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "COMPLETED", Value: _done.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "OVERDUE", Value: _overdue.ToString(), Delta: (string?)null, DeltaDir: (string?)null))
</div>
<div class="pcl-metric-strip-cell">
@await Html.PartialAsync("_Metric", (Label: "VALUE", Value: _value.ToString("C0"), Delta: (string?)null, DeltaDir: (string?)null))
</div>
</div>
@if (!string.IsNullOrEmpty(ViewBag.SearchTerm as string))
{
<div class="alert alert-info alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel me-2"></i>
Showing <strong>@Model.TotalCount</strong> job(s) matching "<strong>@ViewBag.SearchTerm</strong>"
<small class="text-muted ms-2">(searches job number, description, customer, PO, instructions, status, priority)</small>
</div>
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Clear Filter
</a>
</div>
}
@if (!string.IsNullOrEmpty(ViewBag.StatusGroup as string) && ViewBag.StatusGroup != "active" && ViewBag.StatusGroup != "all")
{
var groupLabel = ViewBag.StatusGroup == "overdue" ? "Overdue Jobs (past due date)"
: ViewBag.StatusGroup == "completed" ? "Completed Jobs (completed, ready for pickup & delivered)"
: (string)ViewBag.StatusGroup;
<div class="alert alert-warning alert-permanent d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-funnel-fill me-2"></i>
Showing: <strong>@groupLabel</strong> &mdash; @Model.TotalCount result@(Model.TotalCount == 1 ? "" : "s")
</div>
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-x me-1"></i>Back to On Floor
</a>
</div>
}
@{
var _activeGroup = ViewBag.StatusGroup as string;
var _activeSearch = ViewBag.SearchTerm as string;
// "all" is the explicit show-everything group (bare URL now redirects to "active")
var _noFilter = _activeGroup == "all" && string.IsNullOrEmpty(_activeSearch) && string.IsNullOrEmpty(ViewBag.TagFilter as string);
}
<!-- Jobs Table Card -->
<div class="card border-0 shadow-sm">
<div class="card-header border-0 py-3">
<div class="d-flex flex-column gap-2">
<!-- Row 1: search + actions -->
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center">
<form asp-action="Index" method="get" class="d-flex gap-2 align-items-center flex-grow-1" style="max-width:480px;">
<input type="hidden" name="sortColumn" value="@ViewBag.SortColumn" />
<input type="hidden" name="sortDirection" value="@ViewBag.SortDirection" />
<div class="input-group">
<span class="input-group-text border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" name="searchTerm" class="form-control border-start-0"
id="jobSearchInput"
placeholder="Search jobs… /"
value="@ViewBag.SearchTerm"
aria-label="Search jobs">
<button type="submit" class="btn btn-primary"><i class="bi bi-search"></i></button>
</div>
@if (!string.IsNullOrEmpty(_activeSearch) || !string.IsNullOrEmpty(ViewBag.TagFilter as string))
{
<a href="@Url.Action("Index")" class="btn btn-outline-secondary text-nowrap"><i class="bi bi-x-lg"></i></a>
}
</form>
<div class="d-flex gap-2">
<div class="btn-group">
<a asp-action="Create" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-circle me-1"></i>New Job
</a>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" type="button" id="fromTemplateBtn">
<i class="bi bi-layout-text-window-reverse me-2 text-primary"></i>From Template
</button>
</li>
</ul>
</div>
<a asp-action="Board" class="btn btn-outline-secondary text-nowrap">
<i class="bi bi-kanban me-1"></i>Board
</a>
<a asp-controller="JobTemplates" asp-action="Index" class="btn btn-outline-secondary text-nowrap" title="Manage job templates">
<i class="bi bi-layout-text-window-reverse me-1"></i><span class="d-none d-md-inline">Templates</span>
</a>
<div class="btn-group">
<a href="@Url.Action("Blank", "WorkOrder")" target="_blank" class="btn btn-outline-secondary text-nowrap" title="Print a blank work order form">
<i class="bi bi-printer me-1"></i><span class="d-none d-md-inline">Blank Work Order</span>
</a>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Shop screens</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a asp-action="ShopDisplay" class="dropdown-item" target="_blank">
<i class="bi bi-display me-2 text-primary"></i>Shop Display <small class="text-muted">(TV)</small>
</a>
</li>
<li>
<a asp-action="ShopMobile" class="dropdown-item">
<i class="bi bi-phone me-2 text-primary"></i>Shop Mobile
</a>
</li>
</ul>
</div>
</div>
</div>
<!-- Row 2: quick-view pills -->
<div class="pcl-pill-group">
<a href="@Url.Action("Index", new { statusGroup = "all" })" class="pcl-pill @(_noFilter ? "active" : "")">
All <span class="pcl-pill-count">@_allCount</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="pcl-pill @(_activeGroup == "active" ? "active" : "")">
On floor <span class="pcl-pill-count">@_wip</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "overdue" })" class="pcl-pill @(_activeGroup == "overdue" ? "active" : "")">
Overdue <span class="pcl-pill-count">@_overdue</span>
</a>
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
Ready <span class="pcl-pill-count">@_ready</span>
</a>
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
Completed <span class="pcl-pill-count">@_done</span>
</a>
</div>
</div>
</div>
<div class="card-body p-0">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No jobs found</h5>
<p class="text-muted mb-4">Get started by creating your first job</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th sortable="JobNumber" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="ps-4 th-kicker">Job</th>
<th class="th-kicker">Customer</th>
<th sortable="Status" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Status</th>
<th sortable="Priority" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Priority</th>
<th class="th-kicker">Worker</th>
<th sortable="ScheduledDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Scheduled</th>
<th sortable="DueDate" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker">Due</th>
<th sortable="FinalPrice" current-sort="@ViewBag.SortColumn" current-direction="@ViewBag.SortDirection" class="th-kicker text-end">Price</th>
<th class="th-kicker text-end pe-4">Actions</th>
</tr>
</thead>
<tbody id="jobsTable">
@foreach (var job in Model.Items)
{
var isHot = job.DueDate.HasValue && job.DueDate.Value < DateTime.Now
&& job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP"
&& job.StatusCode != "DELIVERED" && job.StatusCode != "CANCELLED";
<tr class="job-row" data-job-id="@job.Id" style="cursor: pointer;">
<td class="ps-4 @(isHot ? "job-hot-cell" : "")">
<div>
<div class="mono fw-500">
@job.JobNumber
@if (job.IsReworkJob)
{
@await Html.PartialAsync("_StatusChip", (Kind: "warn", Text: "Rework"))
}
</div>
<small class="text-muted">@job.CreatedAt.ToString("MMM dd, yyyy")</small>
@if (!string.IsNullOrWhiteSpace(job.Tags))
{
<div class="mt-1">
@foreach (var tag in job.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(t => t.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)))
{
<a href="@Url.Action("Index", new { tagFilter = tag })" class="pcl-chip pcl-chip-cool me-1 text-decoration-none" onclick="event.stopPropagation();">@tag</a>
}
</div>
}
</div>
</td>
<td>@job.CustomerName</td>
<td>
<span class="pcl-chip pcl-chip-@StatusChipHelper.JobStatus(job.StatusCode) status-badge"
style="cursor:pointer;"
data-job-id="@job.Id"
data-job-number="@job.JobNumber"
data-status-id="@job.JobStatusId"
data-status-name="@job.StatusDisplayName"
data-customer-notify="@job.CustomerNotifyByEmail.ToString().ToLower()"
data-customer-email="@(string.IsNullOrWhiteSpace(job.CustomerEmail) ? "false" : "true")"
title="Click to change status">
<span class="pcl-chip-dot"></span>@job.StatusDisplayName
</span>
</td>
<td>
<span class="pcl-chip pcl-chip-@StatusChipHelper.JobPriority(job.PriorityCode) priority-badge"
style="cursor:pointer;"
data-job-id="@job.Id"
data-job-number="@job.JobNumber"
data-priority-id="@job.JobPriorityId"
data-priority-name="@job.PriorityDisplayName"
title="Click to change priority">
<span class="pcl-chip-dot"></span>@job.PriorityDisplayName
</span>
</td>
<td>
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
{
<span class="badge bg-success-subtle text-success worker-assignment-badge"
style="cursor: pointer;"
data-job-id="@job.Id"
data-job-number="@job.JobNumber"
data-worker-id="@job.AssignedUserId"
data-worker-name="@job.AssignedWorkerName"
title="Click to change worker">
<i class="bi bi-person-badge me-1"></i>@job.AssignedWorkerName
</span>
}
else
{
<span class="text-muted worker-assignment-badge"
style="cursor: pointer;"
data-job-id="@job.Id"
data-job-number="@job.JobNumber"
data-worker-id=""
data-worker-name=""
title="Click to assign worker">
Unassigned
</span>
}
</td>
<td class="date-cell" onclick="openDatePopover(event, @job.Id, 'scheduledDate', '@(job.ScheduledDate.HasValue ? job.ScheduledDate.Value.ToString("yyyy-MM-dd") : "")')" title="Click to change scheduled date" style="cursor:pointer">
<span class="date-display" id="sched-@job.Id">
@if (job.ScheduledDate.HasValue)
{
<span>@job.ScheduledDate.Value.ToString("MMM dd, yyyy")</span>
}
else
{
<span class="text-muted">Not scheduled</span>
}
</span>
<i class="bi bi-pencil-fill ms-1 text-muted date-edit-icon" style="font-size:0.65rem;opacity:0"></i>
</td>
<td class="date-cell" onclick="openDatePopover(event, @job.Id, 'dueDate', '@(job.DueDate.HasValue ? job.DueDate.Value.ToString("yyyy-MM-dd") : "")')" title="Click to change due date" style="cursor:pointer">
<span class="date-display" id="due-@job.Id">
@if (job.DueDate.HasValue)
{
var isOverdue = job.DueDate.Value < DateTime.Now && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "DELIVERED";
<span class="@(isOverdue ? "text-danger fw-semibold" : "")">
@job.DueDate.Value.ToString("MMM dd, yyyy")
@if (isOverdue) { <i class="bi bi-exclamation-triangle ms-1"></i> }
</span>
}
else
{
<span class="text-muted">Not set</span>
}
</span>
<i class="bi bi-pencil-fill ms-1 text-muted date-edit-icon" style="font-size:0.65rem;opacity:0"></i>
</td>
<td class="text-end mono">@job.FinalPrice.ToString("C")</td>
<td class="text-end pe-4">
<div class="btn-group btn-group-sm">
<a asp-action="Details" asp-route-id="@job.Id" class="btn btn-outline-primary" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="Edit" asp-route-id="@job.Id" class="btn btn-outline-warning" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-action="Delete" asp-route-id="@job.Id" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="mobile-card-view">
@if (!Model.Items.Any())
{
<div class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h5 class="mt-3 text-muted">No jobs found</h5>
<p class="text-muted mb-4">Get started by creating your first job</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
</a>
</div>
}
else
{
<div class="mobile-card-list">
@foreach (var job in Model.Items)
{
<div class="mobile-data-card"
data-id="@job.Id"
onclick="window.location.href='@Url.Action("Details", new { id = job.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<i class="bi bi-briefcase"></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">
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.JobStatus(job.StatusCode), Text: job.StatusDisplayName))
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Priority</span>
<span class="mobile-card-value">
@await Html.PartialAsync("_StatusChip", (Kind: StatusChipHelper.JobPriority(job.PriorityCode), Text: job.PriorityDisplayName))
</span>
</div>
@if (!string.IsNullOrEmpty(job.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value">
<i class="bi bi-person-badge me-1 text-muted"></i>@job.AssignedWorkerName
</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 dd, yyyy")</span>
</div>
}
@if (job.DueDate.HasValue)
{
var isOverdue = job.DueDate.Value < DateTime.Now && job.StatusCode != "COMPLETED" && job.StatusCode != "READYFORPICKUP" && job.StatusCode != "DELIVERED";
<div class="mobile-card-row">
<span class="mobile-card-label">Due Date</span>
<span class="mobile-card-value @(isOverdue ? "text-danger fw-semibold" : "")">
@job.DueDate.Value.ToString("MMM dd, yyyy")
@if (isOverdue)
{
<i class="bi bi-exclamation-triangle ms-1"></i>
}
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Price</span>
<span class="mobile-card-value fw-semibold">@job.FinalPrice.ToString("C")</span>
</div>
</div>
<div class="mobile-card-footer">
<a href="@Url.Action("Details", new { id = job.Id })"
class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation();">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="@Url.Action("Edit", new { id = job.Id })"
class="btn btn-sm btn-outline-secondary"
onclick="event.stopPropagation();">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
}
</div>
}
</div>
@if (Model.TotalCount > 0)
{
@await Html.PartialAsync("_Pagination", Model)
}
@if (Model.Items.Any())
{
<div class="pcl-kbd-footer">↑↓ to move &nbsp;·&nbsp; ↵ to open &nbsp;·&nbsp; / to filter</div>
}
</div>
<!-- Antiforgery Token for AJAX -->
@Html.AntiForgeryToken()
<!-- Worker Assignment Modal -->
<div class="modal fade" id="workerAssignmentModal" tabindex="-1" aria-labelledby="workerAssignmentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="workerAssignmentModalLabel">
<i class="bi bi-person-badge me-2"></i>Assign Worker
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="text-muted small mb-1">Job Number</label>
<p class="fw-semibold mb-0" id="modalJobNumber"></p>
</div>
<div class="mb-3">
<label class="text-muted small mb-1">Current Assignment</label>
<p class="mb-0" id="modalCurrentWorker"></p>
</div>
<div>
<label for="workerSelect" class="form-label">Select Worker</label>
<select id="workerSelect" class="form-select">
<option value="">Not assigned</option>
@if (ViewBag.Workers != null)
{
foreach (var worker in (SelectList)ViewBag.Workers)
{
<option value="@worker.Value">@worker.Text</option>
}
}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveWorkerAssignment">
<i class="bi bi-save me-2"></i>Save Assignment
</button>
</div>
</div>
</div>
</div>
<!-- Priority Change Modal -->
<div class="modal fade" id="priorityModal" tabindex="-1" aria-labelledby="priorityModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="priorityModalLabel">
<i class="bi bi-flag me-2"></i>Change Priority
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="text-muted small mb-1">Job Number</label>
<p class="fw-semibold mb-0" id="modalPriorityJobNumber"></p>
</div>
<div class="mb-3">
<label class="text-muted small mb-1">Current Priority</label>
<p class="mb-0" id="modalCurrentPriority"></p>
</div>
<div>
<label for="prioritySelect" class="form-label">Select Priority</label>
<select id="prioritySelect" class="form-select">
<option value="">Loading priorities...</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="savePriority">
<i class="bi bi-save me-2"></i>Save Priority
</button>
</div>
</div>
</div>
</div>
<!-- Status Change Modal -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="statusModalLabel">
<i class="bi bi-arrow-repeat me-2"></i>Change Status
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="text-muted small mb-1">Job Number</label>
<p class="fw-semibold mb-0" id="modalStatusJobNumber"></p>
</div>
<div class="mb-3">
<label class="text-muted small mb-1">Current Status</label>
<p class="mb-0" id="modalCurrentStatus"></p>
</div>
<div class="mb-3">
<label for="statusSelect" class="form-label">Select Status</label>
<select id="statusSelect" class="form-select">
<option value="">Loading statuses...</option>
</select>
</div>
<div id="statusModalEmailRow">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="statusModalSendEmail"
@(ViewBag.EmailDefaultOnStatusChange == true ? "checked" : "") />
<label class="form-check-label small" for="statusModalSendEmail">
<i class="bi bi-envelope me-1"></i>Notify customer via email
</label>
</div>
<div id="statusModalEmailOptOutNote" class="alert alert-warning alert-permanent py-1 px-2 mt-2 small" style="display:none;">
<i class="bi bi-bell-slash me-1"></i>This customer has email notifications turned off.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveStatus">
<i class="bi bi-save me-2"></i>Save Status
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
let currentJobId = null;
let currentJobStatusId = null;
let currentJobPriorityId = null;
let jobStatuses = [];
let jobPriorities = [];
// Load job statuses from lookup table
async function loadJobStatuses() {
try {
const response = await fetch('/CompanySettings/GetJobStatuses');
const data = await response.json();
if (Array.isArray(data)) {
jobStatuses = data.sort((a, b) => a.displayOrder - b.displayOrder);
populateStatusDropdown();
} else {
console.error('Failed to load job statuses:', data);
}
} catch (error) {
console.error('Error loading job statuses:', error);
}
}
function populateStatusDropdown() {
const select = document.getElementById('statusSelect');
select.innerHTML = '';
jobStatuses.forEach(status => {
if (status.isActive) {
const option = document.createElement('option');
option.value = status.id;
option.textContent = status.displayName;
select.appendChild(option);
}
});
}
// Load job priorities from lookup table
async function loadJobPriorities() {
try {
const response = await fetch('/CompanySettings/GetJobPriorities');
const data = await response.json();
if (Array.isArray(data)) {
jobPriorities = data.sort((a, b) => a.displayOrder - b.displayOrder);
populatePriorityDropdown();
} else {
console.error('Failed to load job priorities:', data);
}
} catch (error) {
console.error('Error loading job priorities:', error);
}
}
function populatePriorityDropdown() {
const select = document.getElementById('prioritySelect');
select.innerHTML = '';
jobPriorities.forEach(priority => {
if (priority.isActive) {
const option = document.createElement('option');
option.value = priority.id;
option.textContent = priority.displayName;
select.appendChild(option);
}
});
}
// Load statuses and priorities on page load
loadJobStatuses();
loadJobPriorities();
// / key focuses search input
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
e.preventDefault();
document.getElementById('jobSearchInput')?.focus();
}
});
// Make table rows clickable
document.querySelectorAll('.job-row').forEach(row => {
row.addEventListener('click', function(e) {
// Don't navigate if clicking on action buttons, links, worker badges, priority badges, or status badges
if (e.target.closest('.btn-group') || e.target.closest('a') || e.target.closest('button') ||
e.target.closest('.worker-assignment-badge') || e.target.closest('.priority-badge') ||
e.target.closest('.status-badge') || e.target.closest('.date-cell')) {
return;
}
const jobId = this.getAttribute('data-job-id');
window.location.href = '@Url.Action("Details", "Jobs")/' + jobId;
});
// Hover handled by CSS .table tbody tr:hover
});
// Worker assignment modal
document.querySelectorAll('.worker-assignment-badge').forEach(badge => {
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
currentJobId = this.getAttribute('data-job-id');
const jobNumber = this.getAttribute('data-job-number');
const workerId = this.getAttribute('data-worker-id');
const workerName = this.getAttribute('data-worker-name');
// Update modal content
document.getElementById('modalJobNumber').textContent = jobNumber;
document.getElementById('modalCurrentWorker').textContent = workerName || 'Not assigned';
document.getElementById('workerSelect').value = workerId || '';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('workerAssignmentModal'));
modal.show();
});
});
// Save worker assignment
document.getElementById('saveWorkerAssignment').addEventListener('click', function() {
const workerId = document.getElementById('workerSelect').value;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
// Send AJAX request
fetch('@Url.Action("UpdateWorkerAssignment", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({
jobId: parseInt(currentJobId),
workerId: workerId || null
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close modal
bootstrap.Modal.getInstance(document.getElementById('workerAssignmentModal')).hide();
// Reload page to show updated assignment
location.reload();
} else {
showError('Error updating worker assignment: ' + (data.message || 'Unknown error'), 'Update Failed');
}
})
.catch(error => {
console.error('Error:', error);
showError('An error occurred while updating worker assignment', 'Update Failed');
});
});
// Priority change modal
document.querySelectorAll('.priority-badge').forEach(badge => {
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
currentJobId = this.getAttribute('data-job-id');
currentJobPriorityId = this.getAttribute('data-priority-id');
const jobNumber = this.getAttribute('data-job-number');
const priorityName = this.getAttribute('data-priority-name');
// Update modal content
document.getElementById('modalPriorityJobNumber').textContent = jobNumber;
document.getElementById('modalCurrentPriority').textContent = priorityName;
document.getElementById('prioritySelect').value = currentJobPriorityId;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('priorityModal'));
modal.show();
});
});
// Save priority
document.getElementById('savePriority').addEventListener('click', function() {
const priorityId = document.getElementById('prioritySelect').value;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
// Send AJAX request
fetch('@Url.Action("UpdateJobPriority", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({
jobId: parseInt(currentJobId),
priorityId: parseInt(priorityId)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close modal
bootstrap.Modal.getInstance(document.getElementById('priorityModal')).hide();
// Reload page to show updated priority
location.reload();
} else {
showError('Error updating job priority: ' + (data.message || 'Unknown error'), 'Update Failed');
}
})
.catch(error => {
console.error('Error:', error);
showError('An error occurred while updating job priority', 'Update Failed');
});
});
// Status change modal
document.querySelectorAll('.status-badge').forEach(badge => {
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
currentJobId = this.getAttribute('data-job-id');
currentJobStatusId = this.getAttribute('data-status-id');
const jobNumber = this.getAttribute('data-job-number');
const statusName = this.getAttribute('data-status-name');
const customerNotify = this.getAttribute('data-customer-notify') !== 'false';
const customerHasEmail = this.getAttribute('data-customer-email') !== 'false';
// Update modal content
document.getElementById('modalStatusJobNumber').textContent = jobNumber;
document.getElementById('modalCurrentStatus').textContent = statusName;
document.getElementById('statusSelect').value = currentJobStatusId;
// Show email controls only when the customer has an email address on file
const emailRow = document.getElementById('statusModalEmailRow');
if (emailRow) emailRow.style.display = customerHasEmail ? '' : 'none';
// Reflect customer email opt-out preference
const emailCheckbox = document.getElementById('statusModalSendEmail');
const emailOptOutNote = document.getElementById('statusModalEmailOptOutNote');
if (emailCheckbox && customerHasEmail) {
emailCheckbox.disabled = !customerNotify;
if (!customerNotify) emailCheckbox.checked = false;
}
if (emailOptOutNote) emailOptOutNote.style.display = (customerHasEmail && !customerNotify) ? 'block' : 'none';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('statusModal'));
modal.show();
});
});
// Save status
document.getElementById('saveStatus').addEventListener('click', function() {
const statusId = document.getElementById('statusSelect').value;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const sendEmail = document.getElementById('statusModalSendEmail')?.checked ?? false;
// Send AJAX request
fetch('@Url.Action("UpdateJobStatus", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({
jobId: parseInt(currentJobId),
statusId: parseInt(statusId),
sendEmail: sendEmail
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close modal
bootstrap.Modal.getInstance(document.getElementById('statusModal')).hide();
// Reload page to show updated status
location.reload();
} else {
showError('Error updating job status: ' + (data.message || 'Unknown error'), 'Update Failed');
}
})
.catch(error => {
console.error('Error:', error);
showError('An error occurred while updating job status', 'Update Failed');
});
});
</script>
<style>
.date-cell:hover .date-edit-icon { opacity: 1 !important; }
.date-cell:hover { background-color: var(--bs-table-hover-bg); }
</style>
<script>
// ── Inline date editing (job list) ────────────────────────────────────
let activeDatePopover = null;
function openDatePopover(event, jobId, field, currentValue) {
event.stopPropagation();
// Close any already-open popover
closeDatePopover();
const cell = event.currentTarget;
const label = field === 'scheduledDate' ? 'Scheduled Date' : 'Due Date';
const allowClear = field === 'dueDate';
const wrapper = document.createElement('div');
wrapper.className = 'date-popover shadow rounded border bg-white p-2';
wrapper.style.cssText = 'position:absolute;z-index:1055;min-width:220px;';
wrapper.innerHTML = `
<div class="fw-semibold small text-muted mb-1">${label}</div>
<div class="input-group input-group-sm">
<input type="date" class="form-control" id="dp-input" value="${currentValue}">
<button class="btn btn-primary" onclick="saveDateFromPopover(${jobId},'${field}')" title="Save"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-outline-secondary" onclick="closeDatePopover()" title="Cancel"><i class="bi bi-x-lg"></i></button>
</div>
${allowClear ? `<button class="btn btn-link btn-sm p-0 mt-1 text-danger" onclick="saveDateFromPopover(${jobId},'${field}',true)"><i class="bi bi-x-circle me-1"></i><small>Clear date</small></button>` : ''}
`;
// Position relative to viewport using absolute on body
document.body.appendChild(wrapper);
const rect = cell.getBoundingClientRect();
wrapper.style.top = (rect.bottom + window.scrollY + 4) + 'px';
wrapper.style.left = (rect.left + window.scrollX) + 'px';
activeDatePopover = wrapper;
wrapper.querySelector('#dp-input').focus();
// Close on outside click
setTimeout(() => document.addEventListener('click', outsideClickHandler), 0);
}
function outsideClickHandler(e) {
if (activeDatePopover && !activeDatePopover.contains(e.target)) {
closeDatePopover();
}
}
function closeDatePopover() {
if (activeDatePopover) {
activeDatePopover.remove();
activeDatePopover = null;
document.removeEventListener('click', outsideClickHandler);
}
}
async function saveDateFromPopover(jobId, field, clear = false) {
const input = document.getElementById('dp-input');
const value = clear ? '' : (input?.value || '');
const saveBtn = activeDatePopover?.querySelector('.btn-primary');
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>'; }
try {
const resp = await fetch('@Url.Action("UpdateDates", "Jobs")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''
},
body: JSON.stringify({
jobId,
scheduledDate: field === 'scheduledDate' ? value : undefined,
dueDate: field === 'dueDate' ? value : undefined
})
});
const result = await resp.json();
if (!result.success) throw new Error(result.message);
// Update display cell in place
const prefix = field === 'scheduledDate' ? 'sched' : 'due';
const displayEl = document.getElementById(`${prefix}-${jobId}`);
if (displayEl) {
if (value) {
const d = new Date(value + 'T12:00:00');
const formatted = d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
displayEl.innerHTML = `<span>${formatted}</span>`;
} else {
const emptyLabel = field === 'scheduledDate' ? 'Not scheduled' : 'Not set';
displayEl.innerHTML = `<span class="text-muted">${emptyLabel}</span>`;
}
}
closeDatePopover();
} catch (err) {
alert('Could not save date: ' + err.message);
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>'; }
}
}
// ── From Template picker ──────────────────────────────────────────────
document.getElementById('fromTemplateBtn')?.addEventListener('click', async function () {
const modal = new bootstrap.Modal(document.getElementById('fromTemplateModal'));
const list = document.getElementById('templatePickerList');
list.innerHTML = '<div class="text-center p-4"><div class="spinner-border spinner-border-sm"></div></div>';
modal.show();
try {
const resp = await fetch('@Url.Action("GetTemplatesJson", "JobTemplates")');
const templates = await resp.json();
if (!templates.length) {
list.innerHTML = '<p class="text-muted p-3 mb-0">No active templates found. Save a job as a template first.</p>';
return;
}
list.innerHTML = templates.map(t => `
<a href="@Url.Action("Create", "Jobs")?templateId=${t.id}"
class="list-group-item list-group-item-action p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">${t.name}</div>
${t.description ? `<small class="text-muted">${t.description}</small>` : ''}
${t.customerName ? `<div class="small text-muted mt-1"><i class="bi bi-building me-1"></i>${t.customerName}</div>` : ''}
</div>
<div class="text-end flex-shrink-0 ms-3">
<span class="badge bg-secondary">${t.items.length} item${t.items.length !== 1 ? 's' : ''}</span>
${t.usageCount > 0 ? `<div class="small text-muted mt-1">Used ${t.usageCount}×</div>` : ''}
</div>
</div>
</a>`).join('');
} catch {
list.innerHTML = '<p class="text-danger p-3 mb-0">Failed to load templates.</p>';
}
});
</script>
}
<!-- From Template Modal -->
<div class="modal fade" id="fromTemplateModal" tabindex="-1" aria-labelledby="fromTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="fromTemplateModalLabel">
<i class="bi bi-layout-text-window-reverse me-2 text-primary"></i>Create Job from Template
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="list-group list-group-flush" id="templatePickerList"></div>
</div>
<div class="modal-footer justify-content-between">
<a asp-controller="JobTemplates" asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear me-1"></i>Manage Templates
</a>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>