497 lines
21 KiB
JavaScript
497 lines
21 KiB
JavaScript
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
|
|
|
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL || "http://localhost:54321";
|
|
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
|
|
|
// Only the public anon key lives here. Write access is gated by RLS policies
|
|
// that require an `authenticated` JWT obtained via signInWithPassword.
|
|
const supabase = createClient(SUPA_URL, SUPA_KEY, {
|
|
auth: { persistSession: true, storageKey: "mccars.auth" },
|
|
});
|
|
|
|
// ----- DOM -----
|
|
const loginView = document.querySelector("#loginView");
|
|
const adminView = document.querySelector("#adminView");
|
|
const rotateView = document.querySelector("#rotateView");
|
|
const loginForm = document.querySelector("#loginForm");
|
|
const loginError = document.querySelector("#loginError");
|
|
const rotateForm = document.querySelector("#rotateForm");
|
|
const rotateError = document.querySelector("#rotateError");
|
|
const logoutBtn = document.querySelector("#logoutBtn");
|
|
const changePwBtn = document.querySelector("#changePwBtn");
|
|
const adminWho = document.querySelector("#adminWho");
|
|
|
|
const leadsTableBody = document.querySelector("#leadsTable tbody");
|
|
const leadsEmpty = document.querySelector("#leadsEmpty");
|
|
const leadsBadge = document.querySelector("#leadsBadge");
|
|
const customersTableBody = document.querySelector("#customersTable tbody");
|
|
const customersEmpty = document.querySelector("#customersEmpty");
|
|
const customersBadge = document.querySelector("#customersBadge");
|
|
const leadDialog = document.querySelector("#leadDialog");
|
|
const leadDialogTitle = document.querySelector("#leadDialogTitle");
|
|
const leadDialogBody = document.querySelector("#leadDialogBody");
|
|
const leadDialogClose = document.querySelector("#leadDialogClose");
|
|
|
|
const vehicleForm = document.querySelector("#vehicleForm");
|
|
const formFeedback = document.querySelector("#formFeedback");
|
|
const formTitle = document.querySelector("#formTitle");
|
|
const saveBtn = document.querySelector("#saveBtn");
|
|
const resetBtn = document.querySelector("#resetBtn");
|
|
const photoInput = document.querySelector("#photoInput");
|
|
const photoPreview = document.querySelector("#photoPreview");
|
|
const tableBody = document.querySelector("#adminTable tbody");
|
|
|
|
// ----- State -----
|
|
const state = {
|
|
user: null,
|
|
loginPassword: null, // captured at signInWithPassword to block same-value rotation
|
|
leadView: "active", // "active" | "inactive"
|
|
leads: [],
|
|
customers: [],
|
|
vehicles: [],
|
|
vehicleMap: new Map(),
|
|
currentPhotoPath: null,
|
|
realtimeChannel: null,
|
|
forcedRotation: false,
|
|
};
|
|
|
|
// =========================================================================
|
|
// AUTH FLOW
|
|
// =========================================================================
|
|
async function bootstrap() {
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
if (session) {
|
|
// Always fetch fresh user from server so metadata (must_change_password) is current.
|
|
const { data: { user }, error } = await supabase.auth.getUser();
|
|
if (error || !user) { await supabase.auth.signOut(); show("login"); return; }
|
|
await onAuthenticated(user);
|
|
} else {
|
|
show("login");
|
|
}
|
|
}
|
|
|
|
loginForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
loginError.textContent = "";
|
|
const fd = new FormData(loginForm);
|
|
const pw = fd.get("password");
|
|
const { data, error } = await supabase.auth.signInWithPassword({
|
|
email: fd.get("email"),
|
|
password: pw,
|
|
});
|
|
if (error) { loginError.textContent = error.message; return; }
|
|
state.loginPassword = pw;
|
|
await onAuthenticated(data.user);
|
|
});
|
|
|
|
logoutBtn.addEventListener("click", async () => {
|
|
if (state.realtimeChannel) await supabase.removeChannel(state.realtimeChannel);
|
|
await supabase.auth.signOut();
|
|
location.reload();
|
|
});
|
|
|
|
changePwBtn.addEventListener("click", () => {
|
|
state.forcedRotation = false;
|
|
show("rotate");
|
|
});
|
|
|
|
rotateForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
rotateError.textContent = "";
|
|
const fd = new FormData(rotateForm);
|
|
const pw1 = fd.get("pw1");
|
|
const pw2 = fd.get("pw2");
|
|
if (pw1 !== pw2) { rotateError.textContent = "Passwoerter stimmen nicht ueberein."; return; }
|
|
if (pw1.length < 10) { rotateError.textContent = "Mindestens 10 Zeichen."; return; }
|
|
if (state.loginPassword && pw1 === state.loginPassword) {
|
|
rotateError.textContent = "Neues Passwort muss sich vom alten unterscheiden.";
|
|
return;
|
|
}
|
|
|
|
const { error } = await supabase.auth.updateUser({
|
|
password: pw1,
|
|
data: { must_change_password: false },
|
|
});
|
|
if (error) { rotateError.textContent = error.message; return; }
|
|
|
|
state.loginPassword = pw1;
|
|
rotateForm.reset();
|
|
|
|
if (state.forcedRotation) {
|
|
state.forcedRotation = false;
|
|
await enterAdmin();
|
|
} else {
|
|
show("admin");
|
|
}
|
|
});
|
|
|
|
async function onAuthenticated(user) {
|
|
state.user = user;
|
|
adminWho.textContent = user.email;
|
|
|
|
// Force rotation path (first-login bootstrap)
|
|
const meta = user.user_metadata || {};
|
|
if (meta.must_change_password) {
|
|
state.forcedRotation = true;
|
|
show("rotate");
|
|
return;
|
|
}
|
|
await enterAdmin();
|
|
}
|
|
|
|
async function enterAdmin() {
|
|
show("admin");
|
|
await Promise.all([loadVehicles(), loadLeads(), loadCustomers()]);
|
|
renderActiveTab();
|
|
attachRealtime();
|
|
}
|
|
|
|
function show(which) {
|
|
loginView.style.display = which === "login" ? "block" : "none";
|
|
rotateView.style.display = which === "rotate" ? "block" : "none";
|
|
adminView.style.display = which === "admin" ? "block" : "none";
|
|
}
|
|
|
|
// =========================================================================
|
|
// TABS
|
|
// =========================================================================
|
|
const tabButtons = document.querySelectorAll(".admin-tabs .tab");
|
|
const tabPanels = {
|
|
leads: document.querySelector("#tab-leads"),
|
|
customers: document.querySelector("#tab-customers"),
|
|
vehicles: document.querySelector("#tab-vehicles"),
|
|
};
|
|
let activeTab = "leads";
|
|
tabButtons.forEach(btn => btn.addEventListener("click", () => {
|
|
activeTab = btn.dataset.tab;
|
|
tabButtons.forEach(b => b.classList.toggle("active", b === btn));
|
|
for (const [k, el] of Object.entries(tabPanels)) el.style.display = (k === activeTab) ? "block" : "none";
|
|
renderActiveTab();
|
|
}));
|
|
function renderActiveTab() {
|
|
if (activeTab === "leads") renderLeads();
|
|
if (activeTab === "customers") renderCustomers();
|
|
if (activeTab === "vehicles") renderVehicles();
|
|
}
|
|
|
|
// Sub-tabs (active/inactive leads)
|
|
document.querySelectorAll(".sub-tab").forEach(btn => btn.addEventListener("click", () => {
|
|
state.leadView = btn.dataset.lview;
|
|
document.querySelectorAll(".sub-tab").forEach(b => b.classList.toggle("active", b === btn));
|
|
renderLeads();
|
|
}));
|
|
|
|
// =========================================================================
|
|
// VEHICLES (existing CRUD preserved)
|
|
// =========================================================================
|
|
async function loadVehicles() {
|
|
const { data, error } = await supabase
|
|
.from("vehicles")
|
|
.select("*")
|
|
.order("sort_order", { ascending: true });
|
|
if (error) { console.error(error); return; }
|
|
state.vehicles = data || [];
|
|
state.vehicleMap = new Map(state.vehicles.map(v => [v.id, v]));
|
|
}
|
|
|
|
function renderVehicles() {
|
|
tableBody.innerHTML = "";
|
|
for (const v of state.vehicles) {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td><div style="width:56px;height:36px;border-radius:6px;background:#0e1015 center/cover no-repeat;background-image:url('${attr(v.photo_url)}');"></div></td>
|
|
<td><strong>${esc(v.brand)}</strong><br /><span style="color:var(--muted);">${esc(v.model)}</span></td>
|
|
<td>€ ${v.daily_price_eur}</td>
|
|
<td>${v.is_active ? "✅" : "—"}</td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn small ghost" data-edit="${v.id}">Edit</button>
|
|
<button class="btn small danger" data-del="${v.id}">Del</button>
|
|
</td>`;
|
|
tableBody.appendChild(tr);
|
|
}
|
|
tableBody.querySelectorAll("[data-edit]").forEach(b => b.addEventListener("click", () => loadForEdit(b.dataset.edit)));
|
|
tableBody.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteVehicle(b.dataset.del)));
|
|
}
|
|
|
|
function loadForEdit(id) {
|
|
const v = state.vehicleMap.get(id);
|
|
if (!v) return;
|
|
formTitle.textContent = `Fahrzeug bearbeiten · ${v.brand} ${v.model}`;
|
|
vehicleForm.vid.value = v.id;
|
|
vehicleForm.brand.value = v.brand;
|
|
vehicleForm.model.value = v.model;
|
|
vehicleForm.power_hp.value = v.power_hp;
|
|
vehicleForm.top_speed_kmh.value = v.top_speed_kmh;
|
|
vehicleForm.acceleration.value = v.acceleration;
|
|
vehicleForm.seats.value = v.seats;
|
|
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
|
vehicleForm.sort_order.value = v.sort_order;
|
|
vehicleForm.location.value = v.location;
|
|
vehicleForm.description_de.value = v.description_de;
|
|
vehicleForm.description_en.value = v.description_en;
|
|
vehicleForm.photo_url.value = v.photo_url;
|
|
vehicleForm.is_active.checked = v.is_active;
|
|
state.currentPhotoPath = v.photo_path || null;
|
|
updatePreview(v.photo_url);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
|
|
resetBtn.addEventListener("click", () => {
|
|
vehicleForm.reset();
|
|
vehicleForm.vid.value = "";
|
|
vehicleForm.is_active.checked = true;
|
|
vehicleForm.sort_order.value = 100;
|
|
vehicleForm.location.value = "Steiermark (TBD)";
|
|
vehicleForm.seats.value = 2;
|
|
state.currentPhotoPath = null;
|
|
updatePreview("");
|
|
formTitle.textContent = "Neues Fahrzeug";
|
|
formFeedback.textContent = "";
|
|
});
|
|
|
|
vehicleForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
saveBtn.disabled = true;
|
|
formFeedback.className = "form-feedback";
|
|
formFeedback.textContent = "Saving...";
|
|
try {
|
|
const fd = new FormData(vehicleForm);
|
|
const payload = {
|
|
brand: fd.get("brand"),
|
|
model: fd.get("model"),
|
|
power_hp: +fd.get("power_hp") || 0,
|
|
top_speed_kmh: +fd.get("top_speed_kmh") || 0,
|
|
acceleration: fd.get("acceleration") || "",
|
|
seats: +fd.get("seats") || 2,
|
|
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
|
sort_order: +fd.get("sort_order") || 100,
|
|
location: fd.get("location") || "Steiermark (TBD)",
|
|
description_de: fd.get("description_de") || "",
|
|
description_en: fd.get("description_en") || "",
|
|
photo_url: fd.get("photo_url") || "",
|
|
photo_path: state.currentPhotoPath,
|
|
is_active: !!fd.get("is_active"),
|
|
};
|
|
const vid = fd.get("vid");
|
|
const { error } = vid
|
|
? await supabase.from("vehicles").update(payload).eq("id", vid)
|
|
: await supabase.from("vehicles").insert(payload);
|
|
if (error) throw error;
|
|
formFeedback.textContent = "Gespeichert.";
|
|
await loadVehicles();
|
|
renderVehicles();
|
|
if (!vid) resetBtn.click();
|
|
} catch (err) {
|
|
formFeedback.className = "form-feedback error";
|
|
formFeedback.textContent = err.message || String(err);
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function deleteVehicle(id) {
|
|
const v = state.vehicleMap.get(id);
|
|
if (!v) return;
|
|
if (!confirm(`Delete ${v.brand} ${v.model}?`)) return;
|
|
if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]);
|
|
const { error } = await supabase.from("vehicles").delete().eq("id", id);
|
|
if (error) { alert(error.message); return; }
|
|
await loadVehicles();
|
|
renderVehicles();
|
|
}
|
|
|
|
// Photo upload
|
|
photoInput.addEventListener("change", async () => {
|
|
const file = photoInput.files?.[0];
|
|
if (!file) return;
|
|
formFeedback.className = "form-feedback";
|
|
formFeedback.textContent = "Uploading photo...";
|
|
try {
|
|
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
|
const path = `${crypto.randomUUID()}.${ext}`;
|
|
const { error: upErr } = await supabase.storage
|
|
.from("vehicle-photos")
|
|
.upload(path, file, { contentType: file.type, upsert: false });
|
|
if (upErr) throw upErr;
|
|
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
|
|
state.currentPhotoPath = path;
|
|
vehicleForm.photo_url.value = pub.publicUrl;
|
|
updatePreview(pub.publicUrl);
|
|
formFeedback.textContent = "Upload ok.";
|
|
} catch (err) {
|
|
formFeedback.className = "form-feedback error";
|
|
formFeedback.textContent = err.message || String(err);
|
|
}
|
|
});
|
|
function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; }
|
|
|
|
// =========================================================================
|
|
// LEADS
|
|
// =========================================================================
|
|
async function loadLeads() {
|
|
const { data, error } = await supabase
|
|
.from("leads")
|
|
.select("*")
|
|
.order("created_at", { ascending: false });
|
|
if (error) { console.error(error); return; }
|
|
state.leads = data || [];
|
|
updateBadges();
|
|
}
|
|
|
|
function renderLeads() {
|
|
const wantActive = state.leadView === "active";
|
|
const rows = state.leads.filter(l => l.is_active === wantActive);
|
|
leadsEmpty.style.display = rows.length ? "none" : "block";
|
|
leadsTableBody.innerHTML = "";
|
|
for (const l of rows) {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${fmtDate(l.created_at)}</td>
|
|
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
|
|
<td>${esc(l.vehicle_label || "—")}</td>
|
|
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</td>
|
|
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn small ghost" data-open="${l.id}">Details</button>
|
|
${wantActive ? `
|
|
<button class="btn small" data-qual="${l.id}">Qualifizieren</button>
|
|
<button class="btn small danger" data-disq="${l.id}">Ablehnen</button>
|
|
` : `
|
|
<button class="btn small ghost" data-reopen="${l.id}">Wieder oeffnen</button>
|
|
`}
|
|
</td>`;
|
|
leadsTableBody.appendChild(tr);
|
|
}
|
|
leadsTableBody.querySelectorAll("[data-open]").forEach(b => b.addEventListener("click", () => openLead(b.dataset.open)));
|
|
leadsTableBody.querySelectorAll("[data-qual]").forEach(b => b.addEventListener("click", () => qualifyLead(b.dataset.qual)));
|
|
leadsTableBody.querySelectorAll("[data-disq]").forEach(b => b.addEventListener("click", () => disqualifyLead(b.dataset.disq)));
|
|
leadsTableBody.querySelectorAll("[data-reopen]").forEach(b => b.addEventListener("click", () => reopenLead(b.dataset.reopen)));
|
|
}
|
|
|
|
function openLead(id) {
|
|
const l = state.leads.find(x => x.id === id);
|
|
if (!l) return;
|
|
leadDialogTitle.textContent = `${l.name} · ${l.status}`;
|
|
leadDialogBody.innerHTML = `
|
|
<dl class="kv">
|
|
<dt>Eingang</dt><dd>${fmtDate(l.created_at)}</dd>
|
|
<dt>E-Mail</dt><dd><a href="mailto:${attr(l.email)}">${esc(l.email)}</a></dd>
|
|
<dt>Telefon</dt><dd>${esc(l.phone || "—")}</dd>
|
|
<dt>Fahrzeug</dt><dd>${esc(l.vehicle_label || "—")}</dd>
|
|
<dt>Zeitraum</dt><dd>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</dd>
|
|
<dt>Nachricht</dt><dd style="white-space:pre-wrap;">${esc(l.message || "—")}</dd>
|
|
<dt>Status</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
|
|
<dt>Notiz</dt><dd><textarea id="leadNote" rows="3" style="width:100%;">${esc(l.admin_notes || "")}</textarea></dd>
|
|
</dl>
|
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.8rem;">
|
|
${l.is_active ? `
|
|
<button class="btn danger" id="dlgDisq">Ablehnen</button>
|
|
<button class="btn" id="dlgQual">Qualifizieren</button>
|
|
` : `<button class="btn ghost" id="dlgReopen">Wieder oeffnen</button>`}
|
|
</div>`;
|
|
leadDialog.showModal();
|
|
const note = () => document.querySelector("#leadNote").value;
|
|
document.querySelector("#dlgQual")?.addEventListener("click", () => qualifyLead(l.id, note()));
|
|
document.querySelector("#dlgDisq")?.addEventListener("click", () => disqualifyLead(l.id, note()));
|
|
document.querySelector("#dlgReopen")?.addEventListener("click", () => reopenLead(l.id));
|
|
}
|
|
leadDialogClose.addEventListener("click", () => leadDialog.close());
|
|
|
|
async function qualifyLead(id, notes = "") {
|
|
const { error } = await supabase.rpc("qualify_lead", { p_lead_id: id, p_notes: notes });
|
|
if (error) { alert(error.message); return; }
|
|
leadDialog.open && leadDialog.close();
|
|
// Realtime will refresh; still trigger a quick reload for responsiveness.
|
|
await Promise.all([loadLeads(), loadCustomers()]);
|
|
renderActiveTab();
|
|
}
|
|
async function disqualifyLead(id, notes = "") {
|
|
const { error } = await supabase.rpc("disqualify_lead", { p_lead_id: id, p_notes: notes });
|
|
if (error) { alert(error.message); return; }
|
|
leadDialog.open && leadDialog.close();
|
|
await loadLeads();
|
|
renderLeads();
|
|
updateBadges();
|
|
}
|
|
async function reopenLead(id) {
|
|
if (!confirm("Lead wieder in 'Aktive' verschieben und Kunde ggf. entfernen?")) return;
|
|
const { error } = await supabase.rpc("reopen_lead", { p_lead_id: id });
|
|
if (error) { alert(error.message); return; }
|
|
leadDialog.open && leadDialog.close();
|
|
await Promise.all([loadLeads(), loadCustomers()]);
|
|
renderActiveTab();
|
|
}
|
|
|
|
// =========================================================================
|
|
// CUSTOMERS
|
|
// =========================================================================
|
|
async function loadCustomers() {
|
|
const { data, error } = await supabase
|
|
.from("customers")
|
|
.select("*")
|
|
.order("created_at", { ascending: false });
|
|
if (error) { console.error(error); return; }
|
|
state.customers = data || [];
|
|
updateBadges();
|
|
}
|
|
|
|
function renderCustomers() {
|
|
customersEmpty.style.display = state.customers.length ? "none" : "block";
|
|
customersTableBody.innerHTML = "";
|
|
for (const c of state.customers) {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${fmtDate(c.first_contacted_at)}</td>
|
|
<td><strong>${esc(c.name)}</strong><br /><span class="muted">${esc(c.email)}</span></td>
|
|
<td>${esc(c.phone || "—")}</td>
|
|
<td><code class="muted">${esc(c.lead_id?.slice(0, 8) || "—")}</code></td>
|
|
<td><span class="pill pill-${esc(c.status)}">${esc(c.status)}</span></td>
|
|
<td style="white-space:nowrap;">
|
|
<button class="btn small ghost" data-toggle="${c.id}" data-status="${c.status}">
|
|
${c.status === "active" ? "Inaktiv setzen" : "Aktiv setzen"}
|
|
</button>
|
|
</td>`;
|
|
customersTableBody.appendChild(tr);
|
|
}
|
|
customersTableBody.querySelectorAll("[data-toggle]").forEach(b => b.addEventListener("click", async () => {
|
|
const id = b.dataset.toggle;
|
|
const next = b.dataset.status === "active" ? "inactive" : "active";
|
|
const { error } = await supabase.from("customers").update({ status: next }).eq("id", id);
|
|
if (error) { alert(error.message); return; }
|
|
await loadCustomers();
|
|
renderCustomers();
|
|
}));
|
|
}
|
|
|
|
function updateBadges() {
|
|
const active = state.leads.filter(l => l.is_active).length;
|
|
leadsBadge.textContent = String(active);
|
|
customersBadge.textContent = String(state.customers.length);
|
|
}
|
|
|
|
// =========================================================================
|
|
// REALTIME
|
|
// =========================================================================
|
|
function attachRealtime() {
|
|
if (state.realtimeChannel) return;
|
|
state.realtimeChannel = supabase
|
|
.channel("mccars-admin")
|
|
.on("postgres_changes", { event: "*", schema: "public", table: "leads" }, async () => { await loadLeads(); if (activeTab === "leads") renderLeads(); })
|
|
.on("postgres_changes", { event: "*", schema: "public", table: "customers" }, async () => { await loadCustomers(); if (activeTab === "customers") renderCustomers(); })
|
|
.on("postgres_changes", { event: "*", schema: "public", table: "vehicles" }, async () => { await loadVehicles(); if (activeTab === "vehicles") renderVehicles(); })
|
|
.subscribe();
|
|
}
|
|
|
|
// =========================================================================
|
|
// HELPERS
|
|
// =========================================================================
|
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
|
function attr(s) { return esc(s); }
|
|
function fmtDate(iso) {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
return d.toLocaleString("de-AT", { dateStyle: "short", timeStyle: "short" });
|
|
}
|
|
|
|
bootstrap();
|