506 lines
20 KiB
HTML
506 lines
20 KiB
HTML
{% extends "layout/base.html" %}
|
|
{% block content %}
|
|
<h2 class="mb-3">Customers</h2>
|
|
|
|
{% if can_manage %}
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<form method="post" action="{{ url_for('main.customers_create') }}" class="d-flex align-items-center gap-2 mb-0" autocomplete="off" data-1p-ignore="true" data-lpignore="true">
|
|
<input type="text" name="name" class="form-control form-control-sm" placeholder="New customer name" required style="max-width: 320px;" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore="true" data-lpignore="true" data-form-type="other" />
|
|
<div class="form-check form-check-inline mb-0">
|
|
<input class="form-check-input" type="checkbox" name="active" id="newCustomerActive" checked />
|
|
<label class="form-check-label small" for="newCustomerActive">Active</label>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm" style="white-space: nowrap;">Add</button>
|
|
</form>
|
|
|
|
<form method="post" action="{{ url_for('main.customers_import') }}" enctype="multipart/form-data" class="d-flex align-items-center gap-2 mb-0">
|
|
<input type="file" name="file" accept=".csv,text/csv" class="form-control form-control-sm" required style="max-width: 420px;" />
|
|
<button type="submit" class="btn btn-outline-secondary btn-sm" style="white-space: nowrap;">Import CSV</button>
|
|
</form>
|
|
|
|
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.customers_export') }}">Export CSV</a>
|
|
|
|
{% if autotask_enabled and autotask_configured %}
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="autotaskRefreshAllMappingsBtn" style="white-space: nowrap;">Refresh all Autotask mappings</button>
|
|
<span class="small text-muted" id="autotaskRefreshAllMappingsMsg"></span>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th scope="col">Customer</th>
|
|
<th scope="col">Active</th>
|
|
<th scope="col">Number of jobs</th>
|
|
<th scope="col">Autotask company</th>
|
|
<th scope="col">Autotask mapping</th>
|
|
{% if can_manage %}
|
|
<th scope="col">Actions</th>
|
|
{% endif %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% if customers %}
|
|
{% for c in customers %}
|
|
<tr>
|
|
<td>{{ c.name }}</td>
|
|
<td>
|
|
{% if c.active %}
|
|
<span class="badge bg-success">Active</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">Inactive</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<td>
|
|
{% if c.job_count > 0 %}
|
|
{{ c.job_count }}
|
|
{% else %}
|
|
<span class="text-danger fw-bold">0</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<td>
|
|
{% if c.autotask_company_id %}
|
|
<span class="fw-semibold">{{ c.autotask_company_name or 'Unknown' }}</span>
|
|
<div class="text-muted small">ID: {{ c.autotask_company_id }}</div>
|
|
{% else %}
|
|
<span class="text-muted">Not mapped</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<td>
|
|
{% set st = (c.autotask_mapping_status or '').lower() %}
|
|
{% if not c.autotask_company_id %}
|
|
<span class="badge bg-secondary">Not mapped</span>
|
|
{% elif st == 'ok' %}
|
|
<span class="badge bg-success">OK</span>
|
|
{% elif st == 'renamed' %}
|
|
<span class="badge bg-warning text-dark">Renamed</span>
|
|
{% elif st == 'missing' %}
|
|
<span class="badge bg-warning text-dark">Missing</span>
|
|
{% elif st == 'invalid' %}
|
|
<span class="badge bg-danger">Invalid</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">Unknown</span>
|
|
{% endif %}
|
|
|
|
{% if c.autotask_last_sync_at %}
|
|
<div class="text-muted small">Checked: {{ c.autotask_last_sync_at }}</div>
|
|
{% endif %}
|
|
</td>
|
|
{% if can_manage %}
|
|
<td>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-outline-primary me-1 customer-edit-btn"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#editCustomerModal"
|
|
data-id="{{ c.id }}"
|
|
data-name="{{ c.name }}"
|
|
data-active="{{ '1' if c.active else '0' }}"
|
|
data-autotask-company-id="{{ c.autotask_company_id or '' }}"
|
|
data-autotask-company-name="{{ c.autotask_company_name or '' }}"
|
|
data-autotask-mapping-status="{{ c.autotask_mapping_status or '' }}"
|
|
data-autotask-last-sync-at="{{ c.autotask_last_sync_at or '' }}"
|
|
>
|
|
Edit
|
|
</button>
|
|
<form
|
|
method="post"
|
|
action="{{ url_for('main.customers_delete', customer_id=c.id) }}"
|
|
class="d-inline"
|
|
onsubmit="return confirm('Are you sure you want to delete this customer? All related jobs and mails will be removed.');"
|
|
>
|
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
|
Delete
|
|
</button>
|
|
</form>
|
|
</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="{% if can_manage %}6{% else %}5{% endif %}" class="text-center text-muted py-3">
|
|
No customers found.
|
|
</td>
|
|
</tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{% if can_manage %}
|
|
<!-- Edit customer modal -->
|
|
<div class="modal fade" id="editCustomerModal" tabindex="-1" aria-labelledby="editCustomerModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<form method="post" id="editCustomerForm" autocomplete="off" data-1p-ignore="true" data-lpignore="true">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="editCustomerModalLabel">Edit customer</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 for="edit_customer_name" class="form-label">Customer name</label>
|
|
<input
|
|
type="text"
|
|
class="form-control"
|
|
id="edit_customer_name"
|
|
name="name"
|
|
autocomplete="off"
|
|
autocorrect="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
data-1p-ignore="true"
|
|
data-lpignore="true"
|
|
data-form-type="other"
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="form-check">
|
|
<input
|
|
class="form-check-input"
|
|
type="checkbox"
|
|
id="edit_customer_active"
|
|
name="active"
|
|
/>
|
|
<label class="form-check-label" for="edit_customer_active">
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
<hr class="my-4" />
|
|
|
|
<h6 class="mb-2">Autotask mapping</h6>
|
|
{% if autotask_enabled and autotask_configured %}
|
|
<div class="mb-2">
|
|
<div class="small text-muted">Current mapping</div>
|
|
<div id="autotaskCurrentMapping" class="fw-semibold">Not mapped</div>
|
|
<div id="autotaskCurrentMappingMeta" class="text-muted small"></div>
|
|
</div>
|
|
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="text" class="form-control" id="autotaskCompanySearch" placeholder="Search Autotask companies" autocomplete="off" />
|
|
<button class="btn btn-outline-secondary" type="button" id="autotaskCompanySearchBtn">Search</button>
|
|
</div>
|
|
|
|
<div id="autotaskCompanyResults" class="border rounded p-2" style="max-height: 220px; overflow:auto;"></div>
|
|
|
|
<div class="d-flex gap-2 mt-2">
|
|
<button type="button" class="btn btn-sm btn-outline-primary" id="autotaskSetMappingBtn" disabled>Set mapping</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="autotaskRefreshMappingBtn">Refresh status</button>
|
|
<button type="button" class="btn btn-sm btn-outline-danger" id="autotaskClearMappingBtn">Clear mapping</button>
|
|
</div>
|
|
|
|
<div id="autotaskMappingMsg" class="small text-muted mt-2"></div>
|
|
{% else %}
|
|
<div class="text-muted small">
|
|
Autotask integration is not available. Enable and configure it in Settings → Extensions & Integrations → Autotask.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
var editModalEl = document.getElementById("editCustomerModal");
|
|
if (!editModalEl) {
|
|
return;
|
|
}
|
|
|
|
var editForm = document.getElementById("editCustomerForm");
|
|
var nameInput = document.getElementById("edit_customer_name");
|
|
var activeInput = document.getElementById("edit_customer_active");
|
|
|
|
// Top-level refresh-all (only present when integration is enabled/configured)
|
|
var refreshAllBtn = document.getElementById("autotaskRefreshAllMappingsBtn");
|
|
var refreshAllMsg = document.getElementById("autotaskRefreshAllMappingsMsg");
|
|
|
|
// Autotask mapping UI (only present when integration is enabled/configured)
|
|
var atCurrent = document.getElementById("autotaskCurrentMapping");
|
|
var atCurrentMeta = document.getElementById("autotaskCurrentMappingMeta");
|
|
var atSearchInput = document.getElementById("autotaskCompanySearch");
|
|
var atSearchBtn = document.getElementById("autotaskCompanySearchBtn");
|
|
var atResults = document.getElementById("autotaskCompanyResults");
|
|
var atMsg = document.getElementById("autotaskMappingMsg");
|
|
var atSetBtn = document.getElementById("autotaskSetMappingBtn");
|
|
var atRefreshBtn = document.getElementById("autotaskRefreshMappingBtn");
|
|
var atClearBtn = document.getElementById("autotaskClearMappingBtn");
|
|
|
|
var currentCustomerId = null;
|
|
var selectedCompanyId = null;
|
|
|
|
function setRefreshAllMsg(text, isError) {
|
|
if (!refreshAllMsg) {
|
|
return;
|
|
}
|
|
refreshAllMsg.textContent = text || "";
|
|
if (isError) {
|
|
refreshAllMsg.classList.remove("text-muted");
|
|
refreshAllMsg.classList.add("text-danger");
|
|
} else {
|
|
refreshAllMsg.classList.remove("text-danger");
|
|
refreshAllMsg.classList.add("text-muted");
|
|
}
|
|
}
|
|
|
|
function setMsg(text, isError) {
|
|
if (!atMsg) {
|
|
return;
|
|
}
|
|
atMsg.textContent = text || "";
|
|
if (isError) {
|
|
atMsg.classList.remove("text-muted");
|
|
atMsg.classList.add("text-danger");
|
|
} else {
|
|
atMsg.classList.remove("text-danger");
|
|
atMsg.classList.add("text-muted");
|
|
}
|
|
}
|
|
|
|
function renderCurrentMapping(companyId, companyName, mappingStatus, lastSyncAt) {
|
|
if (!atCurrent || !atCurrentMeta) {
|
|
return;
|
|
}
|
|
if (!companyId) {
|
|
atCurrent.textContent = "Not mapped";
|
|
atCurrentMeta.textContent = "";
|
|
return;
|
|
}
|
|
atCurrent.textContent = (companyName || "Unknown") + " (ID: " + companyId + ")";
|
|
|
|
var parts = [];
|
|
if (mappingStatus) {
|
|
parts.push("Status: " + mappingStatus);
|
|
}
|
|
if (lastSyncAt) {
|
|
parts.push("Checked: " + lastSyncAt);
|
|
}
|
|
atCurrentMeta.textContent = parts.join(" • ");
|
|
}
|
|
|
|
function clearResults() {
|
|
if (!atResults) {
|
|
return;
|
|
}
|
|
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
|
|
}
|
|
|
|
function setSelectedCompanyId(cid) {
|
|
selectedCompanyId = cid;
|
|
if (atSetBtn) {
|
|
atSetBtn.disabled = !selectedCompanyId;
|
|
}
|
|
}
|
|
|
|
async function postJson(url, body) {
|
|
var resp = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(body || {}),
|
|
});
|
|
var data = null;
|
|
try {
|
|
data = await resp.json();
|
|
} catch (e) {
|
|
data = null;
|
|
}
|
|
if (!resp.ok) {
|
|
var msg = (data && data.message) ? data.message : ("Request failed (" + resp.status + ").");
|
|
throw new Error(msg);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
if (refreshAllBtn) {
|
|
refreshAllBtn.addEventListener("click", async function () {
|
|
if (!confirm("Refresh mapping status for all mapped customers?")) {
|
|
return;
|
|
}
|
|
refreshAllBtn.disabled = true;
|
|
setRefreshAllMsg("Refreshing...", false);
|
|
try {
|
|
var data = await postJson("/api/customers/autotask-mapping/refresh-all", {});
|
|
var counts = (data && data.counts) ? data.counts : null;
|
|
if (counts) {
|
|
setRefreshAllMsg(
|
|
"Done. OK: " + (counts.ok || 0) + ", Renamed: " + (counts.renamed || 0) + ", Missing: " + (counts.missing || 0) + ", Invalid: " + (counts.invalid || 0) + ".",
|
|
false
|
|
);
|
|
} else {
|
|
setRefreshAllMsg("Done.", false);
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
setRefreshAllMsg(e && e.message ? e.message : "Refresh failed.", true);
|
|
refreshAllBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
var editButtons = document.querySelectorAll(".customer-edit-btn");
|
|
editButtons.forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var id = btn.getAttribute("data-id");
|
|
var name = btn.getAttribute("data-name") || "";
|
|
var active = btn.getAttribute("data-active") === "1";
|
|
|
|
nameInput.value = name;
|
|
activeInput.checked = active;
|
|
|
|
if (id) {
|
|
editForm.action = "{{ url_for('main.customers_edit', customer_id=0) }}".replace("0", id);
|
|
}
|
|
|
|
// Autotask: seed current mapping from row data attributes
|
|
currentCustomerId = id || null;
|
|
if (atResults) {
|
|
clearResults();
|
|
}
|
|
setSelectedCompanyId(null);
|
|
setMsg("", false);
|
|
|
|
if (atCurrent) {
|
|
var atCompanyId = btn.getAttribute("data-autotask-company-id") || "";
|
|
var atCompanyName = btn.getAttribute("data-autotask-company-name") || "";
|
|
var atStatus = btn.getAttribute("data-autotask-mapping-status") || "";
|
|
var atLast = btn.getAttribute("data-autotask-last-sync-at") || "";
|
|
renderCurrentMapping(atCompanyId, atCompanyName, atStatus, atLast);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (atSearchBtn && atSearchInput && atResults) {
|
|
atSearchBtn.addEventListener("click", async function () {
|
|
var q = (atSearchInput.value || "").trim();
|
|
if (!q) {
|
|
setMsg("Enter a search term.", true);
|
|
return;
|
|
}
|
|
setMsg("Searching...", false);
|
|
setSelectedCompanyId(null);
|
|
atResults.innerHTML = "<div class=\"text-muted small\">Searching...</div>";
|
|
|
|
try {
|
|
var resp = await fetch("/api/autotask/companies/search?q=" + encodeURIComponent(q), {
|
|
method: "GET",
|
|
credentials: "same-origin",
|
|
});
|
|
var data = await resp.json();
|
|
if (!resp.ok || !data || data.status !== "ok") {
|
|
throw new Error((data && data.message) ? data.message : "Search failed.");
|
|
}
|
|
var items = data.items || [];
|
|
if (!items.length) {
|
|
atResults.innerHTML = "<div class=\"text-muted small\">No companies found.</div>";
|
|
setMsg("No companies found.", false);
|
|
return;
|
|
}
|
|
var html = "";
|
|
items.forEach(function (it) {
|
|
var cid = it.id;
|
|
var name = it.companyName || it.name || ("Company #" + cid);
|
|
var active = (it.isActive === false) ? " (inactive)" : "";
|
|
html +=
|
|
"<div class=\"form-check\">" +
|
|
"<input class=\"form-check-input\" type=\"radio\" name=\"autotaskCompanyPick\" id=\"at_company_" + cid + "\" value=\"" + cid + "\" />" +
|
|
"<label class=\"form-check-label\" for=\"at_company_" + cid + "\">" +
|
|
name.replace(/</g, "<").replace(/>/g, ">") +
|
|
" <span class=\"text-muted\">(ID: " + cid + ")</span>" +
|
|
"<span class=\"text-muted\">" + active + "</span>" +
|
|
"</label>" +
|
|
"</div>";
|
|
});
|
|
atResults.innerHTML = html;
|
|
|
|
var radios = atResults.querySelectorAll("input[name='autotaskCompanyPick']");
|
|
radios.forEach(function (r) {
|
|
r.addEventListener("change", function () {
|
|
setSelectedCompanyId(r.value);
|
|
setMsg("Selected company ID: " + r.value, false);
|
|
});
|
|
});
|
|
|
|
setMsg("Select a company and click Set mapping.", false);
|
|
} catch (e) {
|
|
atResults.innerHTML = "<div class=\"text-muted small\">No results.</div>";
|
|
setMsg(e && e.message ? e.message : "Search failed.", true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (atSetBtn) {
|
|
atSetBtn.addEventListener("click", async function () {
|
|
if (!currentCustomerId) {
|
|
setMsg("No customer selected.", true);
|
|
return;
|
|
}
|
|
if (!selectedCompanyId) {
|
|
setMsg("Select a company first.", true);
|
|
return;
|
|
}
|
|
atSetBtn.disabled = true;
|
|
setMsg("Saving mapping...", false);
|
|
try {
|
|
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping", { company_id: selectedCompanyId });
|
|
window.location.reload();
|
|
} catch (e) {
|
|
setMsg(e && e.message ? e.message : "Failed to set mapping.", true);
|
|
atSetBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (atRefreshBtn) {
|
|
atRefreshBtn.addEventListener("click", async function () {
|
|
if (!currentCustomerId) {
|
|
setMsg("No customer selected.", true);
|
|
return;
|
|
}
|
|
setMsg("Refreshing status...", false);
|
|
try {
|
|
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/refresh", {});
|
|
window.location.reload();
|
|
} catch (e) {
|
|
setMsg(e && e.message ? e.message : "Refresh failed.", true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (atClearBtn) {
|
|
atClearBtn.addEventListener("click", async function () {
|
|
if (!currentCustomerId) {
|
|
setMsg("No customer selected.", true);
|
|
return;
|
|
}
|
|
if (!confirm("Clear Autotask mapping for this customer?")) {
|
|
return;
|
|
}
|
|
setMsg("Clearing mapping...", false);
|
|
try {
|
|
await postJson("/api/customers/" + currentCustomerId + "/autotask-mapping/clear", {});
|
|
window.location.reload();
|
|
} catch (e) {
|
|
setMsg(e && e.message ? e.message : "Clear failed.", true);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|