Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders

- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:59:32 -04:00
parent 7cbae31916
commit 711cd01cd3
14 changed files with 12725 additions and 22 deletions
@@ -38,6 +38,148 @@ async function cancelSmsConsent() {
}
}
// ── Customer Notes ────────────────────────────────────────────────────────────
async function addCustomerNote(customerId) {
const textarea = document.getElementById('newNoteText');
const importantCb = document.getElementById('newNoteImportant');
const note = textarea?.value?.trim();
if (!note) { toastr.warning('Please enter a note.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('customer-notes-list');
const placeholder = document.getElementById('no-notes-placeholder');
if (placeholder) placeholder.remove();
list.insertAdjacentHTML('afterbegin', data.noteHtml);
textarea.value = '';
if (importantCb) importantCb.checked = false;
toastr.success('Note added.');
} else {
toastr.error(data.message || 'Could not add note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function deleteCustomerNote(customerId, noteId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `noteId=${noteId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
const list = document.getElementById('customer-notes-list');
if (list && list.querySelectorAll('.customer-note-item').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
} else {
toastr.error(data.message || 'Could not delete note.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
// ── Preferred Powders ─────────────────────────────────────────────────────────
let _powderSearchTimer = null;
function searchInventoryItems(term) {
clearTimeout(_powderSearchTimer);
const dropdown = document.getElementById('powderSearchResults');
if (!term || term.length < 2) { if (dropdown) dropdown.innerHTML = ''; return; }
_powderSearchTimer = setTimeout(async () => {
try {
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
const data = await res.json();
if (!dropdown) return;
dropdown.innerHTML = data.length === 0
? '<div class="dropdown-item text-muted small">No results</div>'
: data.map(i => `<button type="button" class="dropdown-item small"
onclick="selectPowder(${i.id}, ${JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : ''))})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku}</span></button>`).join('');
} catch { /* silent */ }
}, 300);
}
function selectPowder(itemId, label) {
document.getElementById('selectedPowderId').value = itemId;
document.getElementById('powderSearchInput').value = label;
const dropdown = document.getElementById('powderSearchResults');
if (dropdown) dropdown.innerHTML = '';
}
async function addPreferredPowder(customerId) {
const itemId = document.getElementById('selectedPowderId')?.value;
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `inventoryItemId=${itemId}&notes=${encodeURIComponent(notes)}`
});
const data = await res.json();
if (data.success) {
const list = document.getElementById('preferred-powders-list');
const placeholder = document.getElementById('no-powders-placeholder');
if (placeholder) placeholder.remove();
const notesHtml = data.notes ? `<div class="text-muted" style="font-size:0.75rem;">${data.notes}</div>` : '';
list.insertAdjacentHTML('beforeend',
`<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="${data.itemId}">
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
<div class="flex-grow-1"><span class="small fw-semibold">${data.itemName}</span>${notesHtml}</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0"
onclick="removePreferredPowder(${customerId}, ${data.itemId})" title="Remove">&times;</button>
</div>`);
document.getElementById('powderSearchInput').value = '';
document.getElementById('selectedPowderId').value = '';
if (document.getElementById('powderNotes')) document.getElementById('powderNotes').value = '';
toastr.success(`${data.itemName} added to preferred powders.`);
} else {
toastr.warning(data.message || 'Could not add powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function removePreferredPowder(customerId, itemId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/RemovePreferredPowder/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `itemId=${itemId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`[data-powder-id="${itemId}"]`)?.remove();
const list = document.getElementById('preferred-powders-list');
if (list && list.querySelectorAll('[data-powder-id]').length === 0)
list.insertAdjacentHTML('afterbegin', '<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>');
} else {
toastr.error(data.message || 'Could not remove powder.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section');
if (!section) return;