Compare commits
31 Commits
951a2f2a06
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69618bfbd5 | |||
| a4f0f330ad | |||
| 62298d1f28 | |||
| 421215d42d | |||
| 16bec05592 | |||
| a5e642bf6d | |||
| 46d8158c67 | |||
| 7be5f2a160 | |||
| 4272ee4f84 | |||
| de69f1184a | |||
| 9bdfc0be70 | |||
| 145079e344 | |||
| 9448cef053 | |||
| 4ec38cc43a | |||
| 99811f9f49 | |||
| 897a8b459c | |||
| 412e93ad83 | |||
| 677cf54d60 | |||
| f4c1a5cf96 | |||
| 0502073bd9 | |||
| cc1bff3be0 | |||
| 880577e498 | |||
| 6b8e04f3e0 | |||
| 230129e05b | |||
| 50479d0755 | |||
| 0084845480 | |||
| eacad5ee4e | |||
| 57c3992d86 | |||
| 4c9cbe9d2c | |||
| 76401469ad | |||
| 14e1c45faf |
+2494
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
/* ─── Constants ──────────────────────────────────────────────────── */
|
||||
const WEBHOOK_URL =
|
||||
"https://n8n.derez.ai/webhook/4ad0c89f-ab8c-45ee-bad1-9321ce94dd64";
|
||||
const INSTANCES_URL =
|
||||
"https://n8n.derez.ai/webhook/bb369f27-244c-4f8a-869b-f787050619a2";
|
||||
const BACKUP_URL =
|
||||
"https://n8n.derez.ai/webhook/69c0c632-df3b-457f-affe-b725f217f9a2";
|
||||
const SERVERS_URL =
|
||||
"https://n8n.derez.ai/webhook/78c0c2b8-e497-4d44-8f26-fec34217513c";
|
||||
const KEYS_URL =
|
||||
"https://n8n.derez.ai/webhook/a001374f-d8c0-4430-9b47-9f1e9ab134c3";
|
||||
const AUTH_URL =
|
||||
"https://n8n.derez.ai/webhook/e256310a-6627-45ba-a221-599751943fe6";
|
||||
const API_BASE = "https://n8n.derez.ai/webhook";
|
||||
|
||||
/* ─── API route paths ────────────────────────────────────────────── */
|
||||
const ROUTES = {
|
||||
auth: "/account/login",
|
||||
dns: "/admin/dns",
|
||||
instances: "/admin/instance",
|
||||
backup: "/admin/backup-repo",
|
||||
servers: "/admin/server",
|
||||
keys: "/admin/key",
|
||||
crm: "/crm",
|
||||
};
|
||||
|
||||
/* ─── State ──────────────────────────────────────────────────────── */
|
||||
let dnsRecords = [];
|
||||
@@ -20,6 +20,9 @@ let backupRecords = [];
|
||||
let serversData = [];
|
||||
let currentServerIdx = 0;
|
||||
let keysData = [];
|
||||
let crmRecords = [];
|
||||
let crmSortKey = null;
|
||||
let crmSortDir = 1;
|
||||
let currentSession = null;
|
||||
let currentEmail = null;
|
||||
|
||||
@@ -55,8 +58,11 @@ async function apiFetch(url, options = {}) {
|
||||
}
|
||||
|
||||
/* ─── API helpers ────────────────────────────────────────────────── */
|
||||
function getApiUrl() {
|
||||
return localStorage.getItem("al_url") || WEBHOOK_URL;
|
||||
function getApiBase() {
|
||||
return (localStorage.getItem("al_url") || API_BASE).replace(/\/$/, "");
|
||||
}
|
||||
function apiUrl(route) {
|
||||
return getApiBase() + route;
|
||||
}
|
||||
function apiHeaders() {
|
||||
return { "Content-Type": "application/json" };
|
||||
@@ -108,7 +114,7 @@ async function doSignIn() {
|
||||
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Signing in…`;
|
||||
|
||||
try {
|
||||
const res = await fetch(AUTH_URL, {
|
||||
const res = await fetch(apiUrl(ROUTES.auth), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -139,7 +145,7 @@ async function doSignIn() {
|
||||
/* ─── Sign Out ───────────────────────────────────────────────────── */
|
||||
function doSignOut() {
|
||||
if (currentSession) {
|
||||
fetch(AUTH_URL, {
|
||||
fetch(apiUrl(ROUTES.auth), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -163,8 +169,6 @@ function doSignOut() {
|
||||
function updateSidebarUser() {
|
||||
document.getElementById("sidebar-username").textContent =
|
||||
currentEmail || "Not signed in";
|
||||
const settingsEl = document.getElementById("settings-session-email");
|
||||
if (settingsEl) settingsEl.textContent = currentEmail || "—";
|
||||
}
|
||||
|
||||
/* ─── Navigation ─────────────────────────────────────────────────── */
|
||||
@@ -185,10 +189,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
|
||||
instances: "Instances",
|
||||
backup: "Backup",
|
||||
servers: "Servers",
|
||||
settings: "Settings",
|
||||
"coming-soon-firewall": "Firewall",
|
||||
"coming-soon-ssl": "SSL / TLS",
|
||||
keys: "Keys",
|
||||
sales: "Sales",
|
||||
crm: "CRM",
|
||||
};
|
||||
document.getElementById("page-title").textContent = labels[key] || key;
|
||||
|
||||
@@ -210,6 +213,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
|
||||
} else if (key === "keys") {
|
||||
topbar.style.display = "";
|
||||
btnRefresh.onclick = keysLoadRecords;
|
||||
} else if (key === "crm") {
|
||||
topbar.style.display = "";
|
||||
btnRefresh.onclick = crmLoadRecords;
|
||||
} else {
|
||||
topbar.style.display = "none";
|
||||
}
|
||||
@@ -227,6 +233,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
|
||||
if (key === "keys" && keysData.length === 0) {
|
||||
keysLoadRecords();
|
||||
}
|
||||
if (key === "crm" && crmRecords.length === 0) {
|
||||
crmLoadRecords();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,7 +278,7 @@ async function dnsLoadRecords() {
|
||||
const tbody = document.getElementById("dns-table-body");
|
||||
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
try {
|
||||
const res = await apiFetch(getApiUrl(), {
|
||||
const res = await apiFetch(apiUrl(ROUTES.dns), {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
@@ -302,7 +311,7 @@ function dnsRender() {
|
||||
.getElementById("dns-filter-type")
|
||||
.value.toUpperCase();
|
||||
|
||||
const HIDDEN_NAMES = ["fr", "n8n", "admin", "@"];
|
||||
const HIDDEN_NAMES = ["fr", "n8n", "usa", "admin", "app", "@"];
|
||||
|
||||
const filtered = dnsRecords.filter((r) => {
|
||||
const name = r.name.toLowerCase();
|
||||
@@ -383,7 +392,7 @@ async function dnsDeleteRecord(btn) {
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(getApiUrl(), {
|
||||
const res = await apiFetch(apiUrl(ROUTES.dns), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
@@ -421,7 +430,7 @@ async function dnsAddRecord() {
|
||||
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Adding…`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(getApiUrl(), {
|
||||
const res = await apiFetch(apiUrl(ROUTES.dns), {
|
||||
method: "POST",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
@@ -452,13 +461,17 @@ function settingsSave() {
|
||||
const url = document.getElementById("setting-url").value.trim();
|
||||
if (url) {
|
||||
localStorage.setItem("al_url", url);
|
||||
toast("Webhook URL saved", "success");
|
||||
} else toast("Enter a URL to save", "warning");
|
||||
toast("API base URL saved", "success");
|
||||
} else {
|
||||
localStorage.removeItem("al_url");
|
||||
document.getElementById("setting-url").value = "";
|
||||
toast("Reverted to default: " + API_BASE, "info");
|
||||
}
|
||||
}
|
||||
|
||||
async function settingsTest() {
|
||||
try {
|
||||
const res = await apiFetch(getApiUrl(), {
|
||||
const res = await apiFetch(apiUrl(ROUTES.dns), {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (res.ok) toast("Connection OK — HTTP " + res.status, "success");
|
||||
@@ -519,9 +532,11 @@ function stripQuotes(s) {
|
||||
/* ─── Instances — Load ───────────────────────────────────────── */
|
||||
async function instancesLoadRecords() {
|
||||
const tbody = document.getElementById("inst-table-body");
|
||||
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
try {
|
||||
const res = await apiFetch(INSTANCES_URL, { headers: apiHeaders() });
|
||||
const res = await apiFetch(apiUrl(ROUTES.instances), {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
// Normalise: [{data:[...]}, ...], plain array, {data:[...]}, or single object
|
||||
@@ -551,12 +566,10 @@ function instancesRender() {
|
||||
const filtered = instancesRecords.filter((r) => {
|
||||
if (!search) return true;
|
||||
return (
|
||||
stripQuotes(r.uuid).toLowerCase().includes(search) ||
|
||||
stripQuotes(r.agent).toLowerCase().includes(search) ||
|
||||
stripQuotes(r.domain).toLowerCase().includes(search) ||
|
||||
stripQuotes(r.server ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
r.uuid?.toLowerCase().includes(search) ||
|
||||
r.agent?.toLowerCase().includes(search) ||
|
||||
r.email?.toLowerCase().includes(search) ||
|
||||
(r.server ?? "").toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -567,7 +580,7 @@ function instancesRender() {
|
||||
|
||||
const tbody = document.getElementById("inst-table-body");
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
|
||||
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
@@ -579,43 +592,28 @@ function instancesRender() {
|
||||
|
||||
tbody.innerHTML = filtered
|
||||
.map((r) => {
|
||||
const uuid = escHtml(r.uuid);
|
||||
const agent = escHtml(stripQuotes(r.agent));
|
||||
const domain = escHtml(stripQuotes(r.domain));
|
||||
const sshPort = escHtml(stripQuotes(r.ssh_port));
|
||||
const ram = escHtml(stripQuotes(r.ram));
|
||||
const cpu = escHtml(stripQuotes(r.cpu));
|
||||
const server = escHtml(stripQuotes(r.server ?? ""));
|
||||
const created = escHtml(stripQuotes(r.created ?? ""));
|
||||
const uuid = escHtml(r.UUID ?? "");
|
||||
const agent = escHtml(r.agent);
|
||||
const product = escHtml(r.product ?? "");
|
||||
const server = escHtml(r.server ?? "");
|
||||
const email = escHtml(r.email ?? "");
|
||||
const period = escHtml(r.period ?? "");
|
||||
const sshStatus = r.SSH ? "✓" : "✗";
|
||||
const backupStatus = r.BACKUP ? "B" : "";
|
||||
const createdStatus = r.created ? "C" : "";
|
||||
const statusParts = [sshStatus, backupStatus, createdStatus].filter(
|
||||
Boolean,
|
||||
);
|
||||
const status = statusParts.length ? statusParts.join(" ") : "—";
|
||||
return `<tr>
|
||||
<td><span class="mono">${uuid}</span></td>
|
||||
<td>${agent}</td>
|
||||
<td><span class="mono">${domain}</span></td>
|
||||
<td class="mono">${sshPort}</td>
|
||||
<td>${ram}</td>
|
||||
<td>${cpu}</td>
|
||||
<td>${product}</td>
|
||||
<td>${server}</td>
|
||||
<td>${created}</td>
|
||||
<td>${email}</td>
|
||||
<td>${period}</td>
|
||||
<td><span class="mono">${status}</span></td>
|
||||
<td style="white-space:nowrap; display:flex; gap:6px;">
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
data-domain="${domain}"
|
||||
data-port="${sshPort}"
|
||||
onclick="instancesCopySSH(this)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
Copy SSH
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
data-uuid="${uuid}"
|
||||
onclick="instancesOpenEdit(this)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
data-uuid="${uuid}"
|
||||
data-agent="${agent}"
|
||||
@@ -663,7 +661,7 @@ async function instancesDelete(btn) {
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(INSTANCES_URL, {
|
||||
const res = await apiFetch(apiUrl(ROUTES.instances), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ uuid }),
|
||||
@@ -721,7 +719,7 @@ async function instancesSaveEdit() {
|
||||
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Saving…`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(INSTANCES_URL, {
|
||||
const res = await apiFetch(apiUrl(ROUTES.instances), {
|
||||
method: "PUT",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
@@ -753,7 +751,9 @@ async function backupLoadRecords() {
|
||||
const tbody = document.getElementById("backup-table-body");
|
||||
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
try {
|
||||
const res = await apiFetch(BACKUP_URL, { headers: apiHeaders() });
|
||||
const res = await apiFetch(apiUrl(ROUTES.backup), {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
// Normalise:
|
||||
@@ -848,7 +848,7 @@ async function backupDelete(btn) {
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(BACKUP_URL, {
|
||||
const res = await apiFetch(apiUrl(ROUTES.backup), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ id }),
|
||||
@@ -886,19 +886,49 @@ async function serversLoadData() {
|
||||
document.getElementById("server-dashboard").innerHTML =
|
||||
`<div class="empty-state"><div class="spinner"></div><p style="margin-top:12px">Loading…</p></div>`;
|
||||
try {
|
||||
const res = await apiFetch(SERVERS_URL, { headers: apiHeaders() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
const raw = Array.isArray(json) ? json : [json];
|
||||
serversData = raw
|
||||
.map((item) => {
|
||||
try {
|
||||
return typeof item.text === "string" ? JSON.parse(item.text) : item;
|
||||
} catch {
|
||||
return null;
|
||||
/* ─── Step 1: Fetch server list with seats ──────────────── */
|
||||
const seatRes = await fetch("https://n8n.derez.ai/webhook/server");
|
||||
if (!seatRes.ok) throw new Error(`Seat API HTTP ${seatRes.status}`);
|
||||
const seatJson = await seatRes.json();
|
||||
const seatList = Array.isArray(seatJson?.data)
|
||||
? seatJson.data
|
||||
: Array.isArray(seatJson)
|
||||
? seatJson
|
||||
: [];
|
||||
if (!seatList.length) throw new Error("No servers in seat endpoint");
|
||||
|
||||
/* ─── Step 2: Fetch full dashboard for each server ─────── */
|
||||
serversData = [];
|
||||
for (const seat of seatList) {
|
||||
const serverName = seat.server;
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
apiUrl(ROUTES.servers) + "?server=" + encodeURIComponent(serverName),
|
||||
{
|
||||
headers: apiHeaders(),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const raw = Array.isArray(json) ? json : [json];
|
||||
for (const item of raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed =
|
||||
typeof item.text === "string" ? JSON.parse(item.text) : item;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!parsed) continue;
|
||||
parsed.seats = seat;
|
||||
serversData.push(parsed);
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch (_) {
|
||||
/* skip servers that fail */
|
||||
}
|
||||
}
|
||||
|
||||
if (serversData.length === 0) throw new Error("No server data in response");
|
||||
currentServerIdx = 0;
|
||||
serversRenderTabs();
|
||||
@@ -932,7 +962,9 @@ function serversRenderTabs() {
|
||||
.map((s, i) => {
|
||||
const status = s.summary?.overall_status ?? "unknown";
|
||||
const color = dotColor[status] ?? "var(--text-muted)";
|
||||
const name = escHtml(s.server?.name ?? `Server ${i + 1}`);
|
||||
const name = escHtml(
|
||||
s.server?.name ?? s.seats?.server ?? `Server ${i + 1}`,
|
||||
);
|
||||
return `<div class="server-tab${i === currentServerIdx ? " active" : ""}" onclick="serversSelectTab(${i})">
|
||||
<span class="server-tab-dot" style="background:${color}"></span>
|
||||
${name}
|
||||
@@ -1059,7 +1091,7 @@ function serversRenderDashboard(idx) {
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:14px">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
|
||||
<span style="font-size:20px;font-weight:700">${escHtml(server.name)}</span>
|
||||
<span style="font-size:20px;font-weight:700">${escHtml(server.name ?? s.seats?.server)}</span>
|
||||
<span class="badge badge-${escHtml(summary.overall_status)}">${summary.overall_status.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px">
|
||||
@@ -1071,9 +1103,6 @@ function serversRenderDashboard(idx) {
|
||||
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;margin-top:3px">Score</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--text-muted);line-height:1.65;border-top:1px solid var(--border);padding-top:12px;margin:0">
|
||||
${escHtml(summary.short_summary)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ─ CPU / Memory / Disk ───────────────────────────────── -->
|
||||
@@ -1137,7 +1166,7 @@ function serversRenderDashboard(idx) {
|
||||
<!-- ─ Security + Containers ──────────────────────────────── -->
|
||||
<div class="dash-grid">
|
||||
|
||||
<div class="card dash-span-2">
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
@@ -1155,26 +1184,46 @@ function serversRenderDashboard(idx) {
|
||||
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;margin-bottom:6px">Exposed Ports</div>
|
||||
<div>${portsHtml}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:8px">Vuln score: ${security.vulnerability_score ?? "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
security.risk_flags?.length
|
||||
? `
|
||||
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:6px;flex-wrap:wrap">
|
||||
${security.risk_flags.map((f) => `<span class="badge badge-medium">${escHtml(f)}</span>`).join("")}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
Containers
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/>
|
||||
</svg>
|
||||
Seats
|
||||
</div>
|
||||
${
|
||||
s.seats
|
||||
? `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div>
|
||||
<div class="dash-stat">${s.seats.Seats ?? "—"}</div>
|
||||
<div class="dash-stat-sub">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="dash-stat">${s.seats["Seats taken"] ?? "—"}</div>
|
||||
<div class="dash-stat-sub">Taken</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="dash-stat" style="color:${(s.seats["Seats Available"] ?? 0) < 2 ? "var(--danger)" : (s.seats["Seats Available"] ?? 0) < 10 ? "var(--warning)" : "var(--success)"}">${s.seats["Seats Available"] ?? "—"}</div>
|
||||
<div class="dash-stat-sub">Available</div>
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="text-muted" style="font-size:13px;padding:8px 0">No seat data available</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
Containers
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
|
||||
<div>
|
||||
<div class="dash-stat" style="color:${containers.running > 0 ? "var(--success)" : "var(--text-muted)"}">${containers.running}</div>
|
||||
@@ -1255,7 +1304,7 @@ async function keysLoadRecords() {
|
||||
const tbody = document.getElementById("keys-table-body");
|
||||
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
try {
|
||||
const res = await apiFetch(KEYS_URL, { headers: apiHeaders() });
|
||||
const res = await apiFetch(apiUrl(ROUTES.keys), { headers: apiHeaders() });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
// Support both [{data:[...]}, ...] and {data:[...]} and flat array
|
||||
@@ -1335,7 +1384,7 @@ async function keysDelete(btn) {
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(KEYS_URL, {
|
||||
const res = await apiFetch(apiUrl(ROUTES.keys), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ name }),
|
||||
@@ -1348,6 +1397,431 @@ async function keysDelete(btn) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── CRM — Load ────────────────────────────────────────────────── */
|
||||
async function crmLoadRecords() {
|
||||
const tbody = document.getElementById("crm-table-body");
|
||||
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
|
||||
try {
|
||||
const res = await apiFetch(apiUrl(ROUTES.crm), {
|
||||
headers: apiHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
// Shape: {data: [{...}, ...]}
|
||||
let data = json?.data ?? json?.response ?? json;
|
||||
// If the outer result wraps in an extra array like [{data:[...]}] handle that too
|
||||
if (Array.isArray(data) && data.length > 0 && data[0]?.data) {
|
||||
data = data[0].data;
|
||||
}
|
||||
crmRecords = Array.isArray(data) ? data : [];
|
||||
crmRender();
|
||||
toast(
|
||||
`Loaded ${crmRecords.length} contact${crmRecords.length !== 1 ? "s" : ""}`,
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
crmRecords = [];
|
||||
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<p style="margin-top:10px; color:var(--danger);">${err.message}</p>
|
||||
</div></td></tr>`;
|
||||
toast("Failed to load CRM contacts: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── CRM — Render table ────────────────────────────────────────── */
|
||||
function crmRender() {
|
||||
const searchName = (
|
||||
document.getElementById("crm-search-name")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchCompany = (
|
||||
document.getElementById("crm-search-company")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchEmail = (
|
||||
document.getElementById("crm-search-email")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchStage = (
|
||||
document.getElementById("crm-search-stage")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchStrategy = (
|
||||
document.getElementById("crm-search-strategy")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchCategory = (
|
||||
document.getElementById("crm-search-category")?.value ?? ""
|
||||
).toLowerCase();
|
||||
const searchAffiliate = (
|
||||
document.getElementById("crm-search-affiliate")?.value ?? ""
|
||||
).toLowerCase();
|
||||
|
||||
let filtered = crmRecords.filter((r) => {
|
||||
const name = (r.Name ?? "").toLowerCase();
|
||||
const company = (r.Company ?? "").toLowerCase();
|
||||
const email = (r.email ?? "").toLowerCase();
|
||||
const stage = (r.Stage ?? "").toLowerCase();
|
||||
const strategy = (r.strategy ?? "").toLowerCase();
|
||||
const category = (r.category ?? "").toLowerCase();
|
||||
const affiliate = (r.affiliate ?? "").toLowerCase();
|
||||
return (
|
||||
(!searchName || name.includes(searchName)) &&
|
||||
(!searchCompany || company.includes(searchCompany)) &&
|
||||
(!searchEmail || email.includes(searchEmail)) &&
|
||||
(!searchStage || stage.includes(searchStage)) &&
|
||||
(!searchStrategy || strategy.includes(searchStrategy)) &&
|
||||
(!searchCategory || category.includes(searchCategory)) &&
|
||||
(!searchAffiliate || affiliate.includes(searchAffiliate))
|
||||
);
|
||||
});
|
||||
|
||||
// Sort
|
||||
if (crmSortKey) {
|
||||
filtered.sort((a, b) => {
|
||||
const va = (a[crmSortKey] ?? "").toString().toLowerCase();
|
||||
const vb = (b[crmSortKey] ?? "").toString().toLowerCase();
|
||||
if (va < vb) return -1 * crmSortDir;
|
||||
if (va > vb) return 1 * crmSortDir;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Update sort arrows
|
||||
document.querySelectorAll(".sort-arrow").forEach((el) => {
|
||||
el.textContent =
|
||||
el.dataset.sort === crmSortKey ? (crmSortDir === 1 ? " ▲" : " ▼") : "";
|
||||
});
|
||||
|
||||
const total = crmRecords.length;
|
||||
const countEl = document.getElementById("crm-record-count");
|
||||
countEl.textContent =
|
||||
filtered.length === total
|
||||
? `${total} contact${total !== 1 ? "s" : ""}`
|
||||
: `${filtered.length} / ${total} contacts`;
|
||||
|
||||
// Compute category counts from all records (unfiltered)
|
||||
const catCounts = {};
|
||||
for (const r of crmRecords) {
|
||||
const cat = (r.category || "Uncategorized").trim();
|
||||
catCounts[cat] = (catCounts[cat] || 0) + 1;
|
||||
}
|
||||
const catContainer = document.getElementById("crm-category-counts");
|
||||
if (catContainer) {
|
||||
catContainer.innerHTML = Object.entries(catCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(
|
||||
([cat, cnt]) =>
|
||||
`<span style="font-size:11px;color:var(--text-muted);background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:1px 6px;white-space:nowrap">${escHtml(cat)}: ${cnt}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Reset check-all state
|
||||
const checkAll = document.getElementById("crm-check-all");
|
||||
if (checkAll) checkAll.checked = false;
|
||||
|
||||
const tbody = document.getElementById("crm-table-body");
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<p>No contacts found</p>
|
||||
</div></td></tr>`;
|
||||
document.getElementById("crm-delete-selected-btn").disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered
|
||||
.map((r) => {
|
||||
const id = r.id;
|
||||
const name = escHtml(r.Name ?? "");
|
||||
const company = escHtml(r.Company ?? "");
|
||||
const email = escHtml(r.email ?? "");
|
||||
const stage = escHtml(r.Stage ?? "");
|
||||
const strategy = escHtml(r.strategy ?? "");
|
||||
const category = escHtml(r.category ?? "");
|
||||
const affiliate = escHtml(r.affiliate ?? "");
|
||||
const dateNext = r.date_next_contact
|
||||
? formatDate(r.date_next_contact)
|
||||
: "—";
|
||||
// Prevent row click when clicking the checkbox
|
||||
return `<tr data-crm-id="${id}" style="cursor:pointer;" onclick="crmOpenEdit(${id})">
|
||||
<td style="cursor:default;" onclick="event.stopPropagation()"><input type="checkbox" class="crm-select" data-id="${id}" onchange="crmUpdateDeleteBtn()" /></td>
|
||||
<td><strong>${name || "—"}</strong></td>
|
||||
<td>${company || "—"}</td>
|
||||
<td><a href="mailto:${email}" style="color:var(--accent)" onclick="event.stopPropagation()">${email || "—"}</a></td>
|
||||
<td><span class="badge ${stage === "Hold" ? "badge-other" : "badge-green"}">${stage || "—"}</span></td>
|
||||
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${strategy}">${strategy || "—"}</td>
|
||||
<td>${category ? escHtml(category) : "—"}</td>
|
||||
<td>${affiliate || "—"}</td>
|
||||
<td class="text-muted">${dateNext}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
crmUpdateDeleteBtn();
|
||||
}
|
||||
|
||||
/* ─── CRM — Clear all filters ──────────────────────────────────── */
|
||||
function crmClearFilters() {
|
||||
[
|
||||
"name",
|
||||
"company",
|
||||
"email",
|
||||
"stage",
|
||||
"strategy",
|
||||
"category",
|
||||
"affiliate",
|
||||
].forEach((f) => {
|
||||
const el = document.getElementById("crm-search-" + f);
|
||||
if (el) el.value = "";
|
||||
});
|
||||
crmRender();
|
||||
}
|
||||
|
||||
/* ─── CRM — Sort ───────────────────────────────────────────────── */
|
||||
function crmToggleSort(key) {
|
||||
if (crmSortKey === key) {
|
||||
crmSortDir *= -1;
|
||||
} else {
|
||||
crmSortKey = key;
|
||||
crmSortDir = 1;
|
||||
}
|
||||
crmRender();
|
||||
}
|
||||
|
||||
/* ─── CRM — Checkbox helpers ────────────────────────────────────── */
|
||||
function crmUpdateDeleteBtn() {
|
||||
const checked = document.querySelectorAll(".crm-select:checked").length;
|
||||
const hasSel = checked > 0;
|
||||
document.getElementById("crm-delete-selected-btn").disabled = !hasSel;
|
||||
document.getElementById("crm-hold-btn").disabled = !hasSel;
|
||||
document.getElementById("crm-active-btn").disabled = !hasSel;
|
||||
const label = hasSel ? `Delete Checked (${checked})` : "Delete Checked";
|
||||
document.getElementById("crm-delete-selected-btn").innerHTML =
|
||||
`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:middle">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg> ${label}`;
|
||||
document.getElementById("crm-hold-btn").textContent = hasSel
|
||||
? `Set Hold (${checked})`
|
||||
: "Set Hold";
|
||||
document.getElementById("crm-active-btn").textContent = hasSel
|
||||
? `Set Active (${checked})`
|
||||
: "Set Active";
|
||||
}
|
||||
|
||||
function crmToggleAll() {
|
||||
const checked = document.getElementById("crm-check-all").checked;
|
||||
document.querySelectorAll(".crm-select").forEach((cb) => {
|
||||
cb.checked = checked;
|
||||
});
|
||||
crmUpdateDeleteBtn();
|
||||
}
|
||||
|
||||
/* ─── CRM — Delete single contact ──────────────────────────────── */
|
||||
async function crmDeleteContact(btn) {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name || `#${id}`;
|
||||
const ok = await confirmDialog(
|
||||
"Delete Contact",
|
||||
`Delete contact “${name}” (id: ${id})? This cannot be undone.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(apiUrl(ROUTES.crm), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ id: Number(id) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
toast(`Contact “${name}” deleted`, "success");
|
||||
await crmLoadRecords();
|
||||
} catch (err) {
|
||||
toast("Delete failed: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── CRM — Delete selected contacts ────────────────────────────── */
|
||||
async function crmDeleteSelected() {
|
||||
const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map(
|
||||
(cb) => Number(cb.dataset.id),
|
||||
);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const ok = await confirmDialog(
|
||||
"Delete Selected Contacts",
|
||||
`Delete ${ids.length} contact${ids.length !== 1 ? "s" : ""}? This cannot be undone.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
// Delete sequentially
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const res = await apiFetch(apiUrl(ROUTES.crm), {
|
||||
method: "DELETE",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast(
|
||||
`Deleted ${ids.length} contact${ids.length !== 1 ? "s" : ""}`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
toast(
|
||||
`Deleted ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
await crmLoadRecords();
|
||||
}
|
||||
|
||||
/* ─── CRM — Set stage for checked contacts ──────────────────────── */
|
||||
async function crmSetStageSelected(stage) {
|
||||
const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map(
|
||||
(cb) => Number(cb.dataset.id),
|
||||
);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const ok = await confirmDialog(
|
||||
`Set Stage to ${stage}`,
|
||||
`Set stage to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}?`,
|
||||
stage,
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
const r = crmRecords.find((c) => c.id === id);
|
||||
if (!r) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const payload = {
|
||||
id,
|
||||
Name: r.Name ?? "",
|
||||
Company: r.Company ?? "",
|
||||
email: r.email ?? "",
|
||||
Stage: stage,
|
||||
strategy: r.strategy ?? "",
|
||||
category: r.category ?? "",
|
||||
affiliate: r.affiliate ?? "",
|
||||
Number: r.Number ?? "",
|
||||
date_next_contact: r.date_next_contact ?? null,
|
||||
History: r.History ?? "",
|
||||
};
|
||||
try {
|
||||
const res = await apiFetch(apiUrl(ROUTES.crm), {
|
||||
method: "POST",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast(
|
||||
`Stage set to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
toast(
|
||||
`Updated ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
await crmLoadRecords();
|
||||
}
|
||||
|
||||
/* ─── CRM — Edit modal ──────────────────────────────────────────── */
|
||||
function crmOpenEdit(id) {
|
||||
const r = crmRecords.find((c) => c.id === id);
|
||||
if (!r) return;
|
||||
|
||||
document.getElementById("crm-edit-id").value = r.id;
|
||||
document.getElementById("crm-edit-name").value = r.Name ?? "";
|
||||
document.getElementById("crm-edit-company").value = r.Company ?? "";
|
||||
document.getElementById("crm-edit-email").value = r.email ?? "";
|
||||
document.getElementById("crm-edit-stage").value = r.Stage ?? "";
|
||||
document.getElementById("crm-edit-strategy").value = r.strategy ?? "";
|
||||
document.getElementById("crm-edit-category").value = r.category ?? "";
|
||||
document.getElementById("crm-edit-affiliate").value = r.affiliate ?? "";
|
||||
document.getElementById("crm-edit-number").value = r.Number ?? "";
|
||||
document.getElementById("crm-edit-date-next").value = r.date_next_contact
|
||||
? r.date_next_contact.split("T")[0]
|
||||
: "";
|
||||
document.getElementById("crm-edit-history").value = r.History ?? "";
|
||||
|
||||
document.getElementById("crm-edit-title").textContent =
|
||||
`Edit Contact — ${r.Name || r.email || `#${r.id}`}`;
|
||||
document.getElementById("crm-edit-backdrop").classList.add("open");
|
||||
setTimeout(() => document.getElementById("crm-edit-name").focus(), 80);
|
||||
}
|
||||
|
||||
function crmCloseEdit() {
|
||||
document.getElementById("crm-edit-backdrop").classList.remove("open");
|
||||
}
|
||||
|
||||
document.getElementById("crm-edit-backdrop")?.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) crmCloseEdit();
|
||||
});
|
||||
|
||||
async function crmSaveEdit() {
|
||||
const id = Number(document.getElementById("crm-edit-id").value);
|
||||
const payload = {
|
||||
id,
|
||||
Name: document.getElementById("crm-edit-name").value.trim(),
|
||||
Company: document.getElementById("crm-edit-company").value.trim(),
|
||||
email: document.getElementById("crm-edit-email").value.trim(),
|
||||
Stage: document.getElementById("crm-edit-stage").value,
|
||||
strategy: document.getElementById("crm-edit-strategy").value.trim(),
|
||||
category: document.getElementById("crm-edit-category").value.trim(),
|
||||
affiliate: document.getElementById("crm-edit-affiliate").value.trim(),
|
||||
Number: document.getElementById("crm-edit-number").value.trim(),
|
||||
date_next_contact:
|
||||
document.getElementById("crm-edit-date-next").value || null,
|
||||
History: document.getElementById("crm-edit-history").value.trim(),
|
||||
};
|
||||
|
||||
const btn = document.getElementById("crm-save-btn");
|
||||
btn.disabled = true;
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Saving…`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(apiUrl(ROUTES.crm), {
|
||||
method: "POST",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
toast(
|
||||
`Contact “${payload.Name || payload.email || `#${id}`}” updated`,
|
||||
"success",
|
||||
);
|
||||
crmCloseEdit();
|
||||
await crmLoadRecords();
|
||||
} catch (err) {
|
||||
toast("Save failed: " + err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Init ────────────────────────────────────────────────────────── */
|
||||
settingsLoad();
|
||||
const _boot = getCookie("al_session");
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
+623
-201
@@ -27,42 +27,8 @@
|
||||
derez.ai
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section-label">Network</div>
|
||||
<div class="nav-item active" data-section="dns">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3" />
|
||||
</svg>
|
||||
DNS Records
|
||||
</div>
|
||||
<div class="nav-item" data-section="coming-soon-firewall">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
Firewall
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section-label">Infrastructure</div>
|
||||
<div class="nav-item" data-section="instances">
|
||||
<div class="nav-item active" data-section="instances">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
@@ -81,6 +47,23 @@
|
||||
</svg>
|
||||
Instances
|
||||
</div>
|
||||
<div class="nav-item" data-section="dns">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3" />
|
||||
</svg>
|
||||
DNS Records
|
||||
</div>
|
||||
<div class="nav-item" data-section="backup">
|
||||
<svg
|
||||
width="16"
|
||||
@@ -98,6 +81,70 @@
|
||||
</svg>
|
||||
Backup
|
||||
</div>
|
||||
<div class="nav-item" data-section="keys">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
|
||||
/>
|
||||
</svg>
|
||||
Keys
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sidebar-section-label"
|
||||
style="
|
||||
margin-top: 28px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
"
|
||||
>
|
||||
Dashboards
|
||||
</div>
|
||||
<div class="nav-item" data-section="crm">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
CRM
|
||||
</div>
|
||||
<div class="nav-item" data-section="sales">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
Sales
|
||||
</div>
|
||||
<div class="nav-item" data-section="servers">
|
||||
<svg
|
||||
width="16"
|
||||
@@ -116,41 +163,6 @@
|
||||
</svg>
|
||||
Servers
|
||||
</div>
|
||||
<div class="nav-item" data-section="coming-soon-ssl">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
SSL / TLS
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section-label">Commercial</div>
|
||||
<div class="nav-item" data-section="keys">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
|
||||
/>
|
||||
</svg>
|
||||
Keys
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div
|
||||
@@ -182,24 +194,6 @@
|
||||
</svg>
|
||||
<span id="sidebar-username">Not signed in</span>
|
||||
</div>
|
||||
<div class="nav-item" data-section="settings">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.07 4.93a10 10 0 0 0-14.14 0M4.93 19.07a10 10 0 0 0 14.14 0M12 2v2M12 20v2M2 12h2M20 12h2"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
onclick="doSignOut()"
|
||||
@@ -265,7 +259,7 @@
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<!-- ─── DNS Section ───────────────────────────────────────── -->
|
||||
<div class="section active" id="section-dns">
|
||||
<div class="section" id="section-dns">
|
||||
<!-- Filter / search bar -->
|
||||
<div
|
||||
class="card"
|
||||
@@ -483,7 +477,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ─── Instances Section ──────────────────────────────────── -->
|
||||
<div class="section" id="section-instances">
|
||||
<div class="section active" id="section-instances">
|
||||
<!-- Search bar -->
|
||||
<div
|
||||
class="card"
|
||||
@@ -551,18 +545,17 @@
|
||||
<tr>
|
||||
<th>UUID</th>
|
||||
<th>Agent</th>
|
||||
<th>Domain</th>
|
||||
<th>SSH Port</th>
|
||||
<th>RAM</th>
|
||||
<th>CPU</th>
|
||||
<th>Product</th>
|
||||
<th>Server</th>
|
||||
<th>Created</th>
|
||||
<th>Email</th>
|
||||
<th>Period</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="inst-table-body">
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="spinner"></div>
|
||||
<p style="margin-top: 12px">
|
||||
@@ -665,67 +658,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Settings Section ──────────────────────────────────── -->
|
||||
<div class="section" id="section-settings">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">API Connection</span>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
class="form-group"
|
||||
style="grid-column: 1/-1"
|
||||
>
|
||||
<label>Webhook Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="setting-url"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 16px">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="settingsSave()"
|
||||
>
|
||||
Save URL
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick="settingsTest()"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Current Session</span>
|
||||
</div>
|
||||
<p
|
||||
style="
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
Signed in as
|
||||
<strong id="settings-session-email">—</strong>.
|
||||
Sign out to switch accounts.
|
||||
</p>
|
||||
<div style="margin-top: 16px">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onclick="doSignOut()"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Keys Section ───────────────────────────────────── -->
|
||||
<div class="section" id="section-keys">
|
||||
<div class="card">
|
||||
@@ -785,25 +717,380 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Coming-soon placeholders ─────────────────────────── -->
|
||||
<div class="section" id="section-coming-soon-firewall">
|
||||
<!-- ─── CRM Section ─────────────────────────── -->
|
||||
<div class="section" id="section-crm">
|
||||
<!-- Action bar -->
|
||||
<div
|
||||
class="card"
|
||||
style="padding: 10px 20px; margin-bottom: 16px"
|
||||
>
|
||||
<div
|
||||
class="form-row"
|
||||
style="
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<div class="form-group" style="flex: 0 0 auto">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
id="crm-hold-btn"
|
||||
onclick="crmSetStageSelected('Hold')"
|
||||
disabled
|
||||
style="
|
||||
white-space: nowrap;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border-hi);
|
||||
"
|
||||
>
|
||||
Set Hold
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0 0 auto">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
id="crm-active-btn"
|
||||
onclick="crmSetStageSelected('Active')"
|
||||
disabled
|
||||
style="
|
||||
white-space: nowrap;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border-hi);
|
||||
"
|
||||
>
|
||||
Set Active
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0 0 auto">
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
id="crm-delete-selected-btn"
|
||||
onclick="crmDeleteSelected()"
|
||||
disabled
|
||||
style="white-space: nowrap"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
|
||||
/>
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path
|
||||
d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"
|
||||
/>
|
||||
</svg>
|
||||
Delete Checked
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
id="crm-category-counts"
|
||||
></div>
|
||||
<span style="flex: 1; min-width: 0"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
|
||||
/>
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
CRM Contacts
|
||||
</span>
|
||||
<span
|
||||
id="crm-record-count"
|
||||
class="text-muted"
|
||||
style="font-size: 12px"
|
||||
></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="crm-check-all"
|
||||
onchange="crmToggleAll()"
|
||||
title="Select / deselect all"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
onclick="crmToggleSort('Name')"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Name
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="Name"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="
|
||||
crmToggleSort('Company')
|
||||
"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Company
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="Company"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="crmToggleSort('email')"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Email
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="email"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="crmToggleSort('Stage')"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Stage
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="Stage"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="
|
||||
crmToggleSort('strategy')
|
||||
"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Strategy
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="strategy"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="
|
||||
crmToggleSort('category')
|
||||
"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Category
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="category"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="
|
||||
crmToggleSort('affiliate')
|
||||
"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Referral
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="affiliate"
|
||||
></span>
|
||||
</th>
|
||||
<th
|
||||
onclick="
|
||||
crmToggleSort(
|
||||
'date_next_contact',
|
||||
)
|
||||
"
|
||||
style="
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
"
|
||||
>
|
||||
Date Next Contact
|
||||
<span
|
||||
class="sort-arrow"
|
||||
data-sort="date_next_contact"
|
||||
></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr class="crm-search-row">
|
||||
<th></th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-name"
|
||||
placeholder="Filter Name…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-company"
|
||||
placeholder="Filter Company…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-email"
|
||||
placeholder="Filter Email…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-stage"
|
||||
placeholder="Filter Stage…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-strategy"
|
||||
placeholder="Filter Strategy…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-category"
|
||||
placeholder="Filter Category…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
</th>
|
||||
<th style="white-space: nowrap">
|
||||
<input
|
||||
type="text"
|
||||
id="crm-search-affiliate"
|
||||
placeholder="Filter Referral…"
|
||||
oninput="crmRender()"
|
||||
class="crm-filter-input"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick="crmClearFilters()"
|
||||
title="Clear all filters"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tbody id="crm-table-body">
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<div class="empty-state">
|
||||
<div class="spinner"></div>
|
||||
<p style="margin-top: 12px">
|
||||
Loading contacts…
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Sales Dashboard ─────────────────────────── -->
|
||||
<div class="section" id="section-sales">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Sales Dashboard</span>
|
||||
</div>
|
||||
<div style="padding: 16px 0 0">
|
||||
<iframe
|
||||
src="https://derez.ai/operations.html"
|
||||
style="
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
"
|
||||
title="Sales Dashboard"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
></iframe>
|
||||
<p
|
||||
style="
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<path
|
||||
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||
/>
|
||||
</svg>
|
||||
<p>Firewall management — coming soon</p>
|
||||
<a
|
||||
href="https://derez.ai/operations.html"
|
||||
target="_blank"
|
||||
style="color: var(--accent)"
|
||||
>
|
||||
Open in new tab ↗
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -817,33 +1104,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="section-coming-soon-ssl">
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<p>SSL / TLS management — coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /content -->
|
||||
</div>
|
||||
@@ -982,6 +1242,168 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CRM EDIT MODAL ════════════════════════════════════ -->
|
||||
<div class="modal-backdrop" id="crm-edit-backdrop">
|
||||
<div class="modal" style="max-width: 640px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="crm-edit-title"
|
||||
>Edit Contact</span
|
||||
>
|
||||
<button
|
||||
class="modal-close"
|
||||
onclick="crmCloseEdit()"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="crm-edit-id" />
|
||||
<div class="form-row" style="flex-wrap: wrap">
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 160px"
|
||||
>
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-name"
|
||||
placeholder="Contact name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 160px"
|
||||
>
|
||||
<label>Company</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-company"
|
||||
placeholder="Company"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 200px"
|
||||
>
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="crm-edit-email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="flex-wrap: wrap">
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 120px"
|
||||
>
|
||||
<label>Stage</label>
|
||||
<select id="crm-edit-stage">
|
||||
<option value="">— Select —</option>
|
||||
<option>Active</option>
|
||||
<option>Hold</option>
|
||||
<option>Blacklist</option>
|
||||
<option>Won</option>
|
||||
<option>Lost</option>
|
||||
<option>Contacted</option>
|
||||
<option>Cold</option>
|
||||
<option>Warm</option>
|
||||
<option>Bounced</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 160px"
|
||||
>
|
||||
<label>Strategy</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-strategy"
|
||||
placeholder="Strategy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 120px"
|
||||
>
|
||||
<label>Category</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-category"
|
||||
placeholder="Category"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" style="flex-wrap: wrap">
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 120px"
|
||||
>
|
||||
<label>Affiliate</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-affiliate"
|
||||
placeholder="Affiliate"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 120px"
|
||||
>
|
||||
<label>Number</label>
|
||||
<input
|
||||
type="text"
|
||||
id="crm-edit-number"
|
||||
placeholder="Phone number"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="form-group"
|
||||
style="flex: 1; min-width: 160px"
|
||||
>
|
||||
<label>Date Next Contact</label>
|
||||
<input type="date" id="crm-edit-date-next" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<label>History / Notes</label>
|
||||
<textarea
|
||||
id="crm-edit-history"
|
||||
rows="4"
|
||||
placeholder="Contact history, notes…"
|
||||
style="width: 100%; resize: vertical"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="crmCloseEdit()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="crm-save-btn"
|
||||
onclick="crmSaveEdit()"
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CONFIRM DIALOG ══════════════════════════════════════════════ -->
|
||||
<div class="modal-backdrop" id="confirm-backdrop">
|
||||
<div class="modal" id="confirm-modal" style="max-width: 360px">
|
||||
|
||||
+118
-73
@@ -8,29 +8,33 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-card: #1a1d27;
|
||||
--bg-hover: #22263a;
|
||||
--bg-input: #12151f;
|
||||
--border: #2a2f45;
|
||||
--accent: #4f8ef7;
|
||||
--accent-dim: #2a4a8a;
|
||||
--danger: #e05252;
|
||||
--danger-dim: #5a1f1f;
|
||||
--success: #3fc97e;
|
||||
--warning: #f0a04b;
|
||||
--text: #e4e8f5;
|
||||
--text-muted: #7a82a0;
|
||||
--bg: #000810;
|
||||
--bg-card: #010f20;
|
||||
--bg-inner: #000d1a;
|
||||
--bg-hover: #001a30;
|
||||
--bg-input: #000813;
|
||||
--border: #0a3060;
|
||||
--border-hi: rgba(0, 245, 255, 0.45);
|
||||
--accent: #00f5ff;
|
||||
--accent-dim: #002535;
|
||||
--danger: #ff3b3b;
|
||||
--danger-dim: #3b0000;
|
||||
--success: #00ff88;
|
||||
--warning: #ffaa00;
|
||||
--text: #c8f0ff;
|
||||
--text-muted: #2e6a80;
|
||||
--sidebar-w: 230px;
|
||||
--header-h: 54px;
|
||||
--radius: 8px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow:
|
||||
0 0 0 1px rgba(0, 245, 255, 0.25), 0 0 60px rgba(0, 245, 255, 0.12),
|
||||
0 28px 72px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
@@ -63,7 +67,7 @@ body {
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.3px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -127,17 +131,23 @@ body {
|
||||
|
||||
.topbar {
|
||||
height: var(--header-h);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-hi);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 245, 255, 0.08),
|
||||
transparent 60%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28px;
|
||||
padding: 0 22px;
|
||||
flex-shrink: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
.topbar-title {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
@@ -161,10 +171,10 @@ body {
|
||||
|
||||
/* ─── Cards ─────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
background: var(--bg-inner);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-header {
|
||||
@@ -176,11 +186,12 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ─── Buttons ───────────────────────────────────────────────── */
|
||||
@@ -206,8 +217,19 @@ body {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow:
|
||||
0 0 8px rgba(0, 245, 255, 0.35),
|
||||
inset 0 0 8px rgba(0, 245, 255, 0.04);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: rgba(0, 245, 255, 0.08);
|
||||
box-shadow:
|
||||
0 0 14px rgba(0, 245, 255, 0.5),
|
||||
inset 0 0 10px rgba(0, 245, 255, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
@@ -223,7 +245,7 @@ body {
|
||||
color: #000;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
@@ -257,7 +279,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
padding: 9px 11px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
@@ -315,44 +337,44 @@ td:last-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-a {
|
||||
background: #1a3a5c;
|
||||
color: #5fb3f5;
|
||||
background: #001a2e;
|
||||
color: #00c8e0;
|
||||
}
|
||||
.badge-aaaa {
|
||||
background: #1a2f4c;
|
||||
color: #8ac8f8;
|
||||
background: #001225;
|
||||
color: #50b8d8;
|
||||
}
|
||||
.badge-cname {
|
||||
background: #2a1f4a;
|
||||
color: #a888f8;
|
||||
background: #0e0028;
|
||||
color: #9060f0;
|
||||
}
|
||||
.badge-mx {
|
||||
background: #1f3a2a;
|
||||
color: #5fd89a;
|
||||
background: #001a0e;
|
||||
color: #00cc60;
|
||||
}
|
||||
.badge-txt {
|
||||
background: #3a2a1a;
|
||||
color: #f0b060;
|
||||
background: #1a1000;
|
||||
color: #cc8800;
|
||||
}
|
||||
.badge-ns {
|
||||
background: #3a1a2a;
|
||||
color: #f080b0;
|
||||
background: #200012;
|
||||
color: #cc5088;
|
||||
}
|
||||
.badge-other {
|
||||
background: #2a2a2a;
|
||||
color: #9090a0;
|
||||
background: #080c12;
|
||||
color: #4a6880;
|
||||
}
|
||||
.badge-eu {
|
||||
background: #1a2f4c;
|
||||
color: #8ac8f8;
|
||||
background: #001225;
|
||||
color: #50b8d8;
|
||||
}
|
||||
.badge-us {
|
||||
background: #1f3a2a;
|
||||
color: #5fd89a;
|
||||
background: #001a0e;
|
||||
color: #00cc60;
|
||||
}
|
||||
.badge-region {
|
||||
background: #2a2a2a;
|
||||
color: #9090a0;
|
||||
background: #080c12;
|
||||
color: #4a6880;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -431,7 +453,7 @@ td:last-child {
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
@@ -484,6 +506,7 @@ td:last-child {
|
||||
/* ─── Confirm dialog ────────────────────────────────────────── */
|
||||
#confirm-backdrop .modal {
|
||||
max-width: 380px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ─── Login modal ───────────────────────────────────────────── */
|
||||
@@ -503,7 +526,7 @@ td:last-child {
|
||||
}
|
||||
#login-modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-hi);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
@@ -518,13 +541,13 @@ td:last-child {
|
||||
}
|
||||
.login-logo h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 17px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.login-logo p {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#login-error {
|
||||
@@ -587,8 +610,8 @@ td:last-child {
|
||||
gap: 8px;
|
||||
}
|
||||
.mono {
|
||||
font-family: "Cascadia Code", "Fira Code", monospace;
|
||||
font-size: 12px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
@@ -611,7 +634,7 @@ hr.sep {
|
||||
.server-tab {
|
||||
padding: 8px 20px 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: var(--text-muted);
|
||||
@@ -624,6 +647,7 @@ hr.sep {
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
@@ -632,14 +656,14 @@ hr.sep {
|
||||
.server-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
border-color: rgba(0, 245, 255, 0.4);
|
||||
border-bottom-color: var(--bg-hover);
|
||||
}
|
||||
.server-tab.active {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
border-bottom-color: var(--bg);
|
||||
color: var(--text);
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
border-bottom-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
.server-tab-dot {
|
||||
display: inline-block;
|
||||
@@ -683,14 +707,14 @@ hr.sep {
|
||||
.prog-wrap {
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
height: 4px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.prog-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
.prog-low {
|
||||
@@ -735,28 +759,28 @@ hr.sep {
|
||||
|
||||
/* ─── Status / risk badges (dashboard) ──────────────────────── */
|
||||
.badge-green {
|
||||
background: #1c3a26;
|
||||
color: #5fd89a;
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge-yellow {
|
||||
background: #372900;
|
||||
color: #f0b060;
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
.badge-red {
|
||||
background: #3a1515;
|
||||
color: #f07070;
|
||||
background: rgba(255, 59, 59, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
.badge-low {
|
||||
background: #1c3a26;
|
||||
color: #5fd89a;
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge-medium {
|
||||
background: #372900;
|
||||
color: #f0b060;
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
.badge-high {
|
||||
background: #3a1515;
|
||||
color: #f07070;
|
||||
background: rgba(255, 59, 59, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Action advice items ─────────────────────────────────── */
|
||||
@@ -795,3 +819,24 @@ hr.sep {
|
||||
font-size: 13px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
/* ─── CRM filter inputs ──────────────────────────────────── */
|
||||
.crm-filter-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.crm-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-hi);
|
||||
}
|
||||
.crm-search-row th {
|
||||
padding: 6px 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user