Add CRM features: Additional Contacts, Lead Source, Ship-To Address; update Help docs

- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:46:08 -04:00
parent 711cd01cd3
commit 94a89ee175
22 changed files with 12586 additions and 31 deletions
@@ -180,6 +180,132 @@ async function removePreferredPowder(customerId, itemId) {
}
}
// ── Customer Contacts ──────────────────────────────────────────────────────
function openAddContactModal() {
document.getElementById('contactId').value = '0';
document.getElementById('contactModalTitle').textContent = 'Add Contact';
document.getElementById('contactFirstName').value = '';
document.getElementById('contactLastName').value = '';
document.getElementById('contactTitle').value = '';
document.getElementById('contactRole').value = '';
document.getElementById('contactEmail').value = '';
document.getElementById('contactPhone').value = '';
document.getElementById('contactMobilePhone').value = '';
document.getElementById('contactNotes').value = '';
document.getElementById('contactModalError').classList.add('d-none');
}
async function editContact(customerId, contactId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/GetContact/${customerId}?contactId=${contactId}`);
const data = await res.json();
if (!data.success) { toastr.error('Could not load contact.'); return; }
const c = data.contact;
document.getElementById('contactId').value = c.id;
document.getElementById('contactModalTitle').textContent = 'Edit Contact';
document.getElementById('contactFirstName').value = c.firstName ?? '';
document.getElementById('contactLastName').value = c.lastName ?? '';
document.getElementById('contactTitle').value = c.title ?? '';
document.getElementById('contactRole').value = c.contactRole ?? '';
document.getElementById('contactEmail').value = c.email ?? '';
document.getElementById('contactPhone').value = c.phone ?? '';
document.getElementById('contactMobilePhone').value = c.mobilePhone ?? '';
document.getElementById('contactNotes').value = c.notes ?? '';
document.getElementById('contactModalError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('contactModal')).show();
} catch {
toastr.error('An error occurred loading the contact.');
}
}
async function saveContact(customerId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const contactId = parseInt(document.getElementById('contactId').value ?? '0', 10);
const firstName = document.getElementById('contactFirstName').value.trim();
if (!firstName) {
const err = document.getElementById('contactModalError');
err.textContent = 'First name is required.';
err.classList.remove('d-none');
return;
}
const params = new URLSearchParams({
FirstName: firstName,
LastName: document.getElementById('contactLastName').value.trim(),
Title: document.getElementById('contactTitle').value.trim(),
ContactRole: document.getElementById('contactRole').value,
Email: document.getElementById('contactEmail').value.trim(),
Phone: document.getElementById('contactPhone').value.trim(),
MobilePhone: document.getElementById('contactMobilePhone').value.trim(),
Notes: document.getElementById('contactNotes').value.trim(),
});
const isEdit = contactId > 0;
if (isEdit) { params.append('Id', contactId); params.append('CustomerId', customerId); }
const url = isEdit ? `/Customers/UpdateContact/${customerId}` : `/Customers/AddContact/${customerId}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: params.toString()
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('contactModal'))?.hide();
const tbody = document.getElementById('contacts-table-body');
const placeholder = document.getElementById('no-contacts-placeholder');
if (placeholder) placeholder.remove();
if (isEdit) {
const existing = tbody.querySelector(`tr[data-contact-id="${contactId}"]`);
if (existing) existing.outerHTML = data.rowHtml;
else tbody.insertAdjacentHTML('beforeend', data.rowHtml);
} else {
tbody.insertAdjacentHTML('beforeend', data.rowHtml);
}
toastr.success(isEdit ? 'Contact updated.' : 'Contact added.');
} else {
const err = document.getElementById('contactModalError');
err.textContent = data.message || 'An error occurred.';
err.classList.remove('d-none');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
async function deleteContact(customerId, contactId) {
if (!confirm('Delete this contact?')) return;
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Customers/DeleteContact/${customerId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
body: `contactId=${contactId}`
});
const data = await res.json();
if (data.success) {
document.querySelector(`tr[data-contact-id="${contactId}"]`)?.remove();
const tbody = document.getElementById('contacts-table-body');
if (tbody && tbody.querySelectorAll('tr[data-contact-id]').length === 0)
tbody.insertAdjacentHTML('afterbegin', '<tr id="no-contacts-placeholder"><td colspan="4" class="text-muted small px-3 py-2">No additional contacts.</td></tr>');
toastr.success('Contact deleted.');
} else {
toastr.error(data.message || 'Could not delete contact.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
window.updateCustomerSmsStatus = function () {
const section = document.getElementById('sms-status-section');
if (!section) return;