Files
mc_cars_gmbh_infraestructure/frontend/admin.js
T

1460 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { getLang, setLang, t, applyI18n } from "./i18n.js";
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
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 langToggle = document.querySelector(".lang-toggle");
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 ordersTableBody = document.querySelector("#ordersTable tbody");
const ordersEmpty = document.querySelector("#ordersEmpty");
const ordersBadge = document.querySelector("#ordersBadge");
const leadDialog = document.querySelector("#leadDialog");
const leadDialogTitle = document.querySelector("#leadDialogTitle");
const leadDialogBody = document.querySelector("#leadDialogBody");
const leadDialogClose = document.querySelector("#leadDialogClose");
const leadDialogTabs = document.querySelector("#leadDialogTabs");
const leadDialogFooter = document.querySelector("#leadDialogFooter");
const customerDialog = document.querySelector("#customerDialog");
const customerDialogTitle = document.querySelector("#customerDialogTitle");
const customerDialogBody = document.querySelector("#customerDialogBody");
const customerDialogClose = document.querySelector("#customerDialogClose");
const customerDialogTabs = document.querySelector("#customerDialogTabs");
const customerDialogFooter = document.querySelector("#customerDialogFooter");
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: [],
salesOrders: [],
vehicles: [],
vehicleMap: new Map(),
currentPhotoPath: null,
realtimeChannel: null,
forcedRotation: false,
};
function notify(message, duration = 3000) {
if (typeof window.showToast === "function") {
window.showToast(message, duration);
return;
}
showAdminPopup(message, duration);
}
function showAdminPopup(message, duration = 3000) {
const host = document.querySelector("dialog[open]") || document.body;
let popup = host.querySelector("[data-admin-notify-popup]");
if (!popup) {
popup = document.createElement("div");
popup.setAttribute("data-admin-notify-popup", "1");
popup.setAttribute("role", "status");
popup.setAttribute("aria-live", "polite");
popup.style.position = host.tagName === "DIALOG" ? "absolute" : "fixed";
popup.style.left = "50%";
popup.style.top = "50%";
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
popup.style.minWidth = "320px";
popup.style.maxWidth = "min(92vw, 560px)";
popup.style.padding = "1rem 1.2rem";
popup.style.borderRadius = "12px";
popup.style.border = "1px solid var(--line)";
popup.style.background = "var(--bg-card)";
popup.style.color = "var(--text)";
popup.style.boxShadow = "0 16px 40px rgba(0,0,0,0.35)";
popup.style.textAlign = "center";
popup.style.fontSize = "1rem";
popup.style.fontWeight = "600";
popup.style.opacity = "0";
popup.style.zIndex = "3000";
popup.style.pointerEvents = "none";
popup.style.transition = "opacity 0.18s ease, transform 0.18s ease";
host.appendChild(popup);
}
popup.textContent = message;
popup.style.opacity = "1";
popup.style.transform = "translate(-50%, -50%) scale(1)";
clearTimeout(popup._hideTimer);
popup._hideTimer = setTimeout(() => {
popup.style.opacity = "0";
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
}, duration);
}
// =========================================================================
// AUTH FLOW
// =========================================================================
async function bootstrap() {
applyI18n();
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 = "Passwörter stimmen nicht überein."; 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(), loadSalesOrders()]);
setActiveTab(getTabFromHash());
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"),
orders: document.querySelector("#tab-orders"),
vehicles: document.querySelector("#tab-vehicles"),
settings: document.querySelector("#tab-settings"),
};
let activeTab = "leads";
function setActiveTab(tab) {
activeTab = tab;
window.location.hash = tab;
tabButtons.forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
for (const [k, el] of Object.entries(tabPanels)) el.style.display = (k === activeTab) ? "block" : "none";
renderActiveTab();
}
tabButtons.forEach(btn => btn.addEventListener("click", () => setActiveTab(btn.dataset.tab)));
// Restore tab from URL hash on load
function getTabFromHash() {
const hash = window.location.hash.replace("#", "");
if (hash && tabPanels[hash]) return hash;
return "leads";
}
window.addEventListener("hashchange", () => {
const tab = getTabFromHash();
if (tab !== activeTab) setActiveTab(tab);
});
function renderActiveTab() {
if (activeTab === "leads") renderLeads();
if (activeTab === "customers") renderCustomers();
if (activeTab === "orders") renderOrders();
if (activeTab === "vehicles") renderVehicles();
if (activeTab === "settings") renderSettings();
}
// 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}">${t("editVehicle")}</button>
<button class="btn small danger" data-del="${v.id}">${t("adminDel")}</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.weekend_price_eur.value = v.weekend_price_eur || 0;
vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
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;
vehicleForm.included_km_per_day.value = 150;
vehicleForm.weekend_price_eur.value = 0;
vehicleForm.kaution_eur.value = 5000;
vehicleForm.price_per_km_eur.value = 1.50;
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,
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
included_km_per_day: +fd.get("included_km_per_day") || 150,
kaution_eur: +fd.get("kaution_eur") || 5000,
price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
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 {
// Delete old photo if exists
if (state.currentPhotoPath) {
await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]);
}
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: true });
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 rental = rentalTypeMeta(l.rental_type);
const total = l.total_eur || 0;
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
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 style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</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}">${t("adminDetails")}</button>
${wantActive ? `
<button class="btn small" data-qual="${l.id}">${t("adminQualify")}</button>
<button class="btn small danger" data-disq="${l.id}">${t("adminReject")}</button>
` : `
<button class="btn small ghost" data-reopen="${l.id}">${t("adminReopen")}</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)));
}
// ----- LEAD DOCUMENTS -----
async function loadLeadAttachments(leadId) {
const { data, error } = await supabase
.from("lead_attachments")
.select("*")
.eq("lead_id", leadId)
.order("created_at", { ascending: false });
if (error) { console.error(error); return []; }
return data || [];
}
async function openAttachmentInNewTab(filePath, bucket = "customer-documents") {
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(filePath, 60 * 5);
if (error || !data?.signedUrl) {
alert(error?.message || "Document URL could not be created.");
return;
}
window.open(data.signedUrl, "_blank", "noopener,noreferrer");
}
function docKindIcon(kind) {
switch (kind) {
case "id_document": return "🪪";
case "income_proof": return "💰";
default: return "📄";
}
}
function docKindLabel(kind) {
const lang = getLang();
switch (kind) {
case "id_document": return lang === "de" ? t("adminIdDoc") : t("adminIdDocEn");
case "income_proof": return lang === "de" ? t("adminIncomeDoc") : t("adminIncomeDocEn");
default: return lang === "de" ? t("adminOtherDoc") : t("adminOtherDocEn");
}
}
function renderDocList(docs, orderLabelById = new Map()) {
if (!docs.length) {
const lang = getLang();
return `<p class="muted" style="text-align:center;padding:1.5rem 0;">${lang === "de" ? t("adminNoDocuments") : t("adminNoDocumentsEn")}</p>`;
}
let html = "";
for (const d of docs) {
html += `
<div class="doc-item">
<div class="doc-info">
<span class="doc-icon">${docKindIcon(d.kind)}</span>
<div>
<strong>${esc(d.file_name)}</strong>
<div class="muted">${docKindLabel(d.kind)} · ${fmtDate(d.created_at)}</div>
${d.sales_order_id ? `<div class="muted">Reservation: ${esc(orderLabelById.get(d.sales_order_id) || d.sales_order_id.slice(0, 8))}</div>` : ""}
</div>
</div>
<a class="btn small ghost" href="#" data-open-file="${d.file_path}" data-open-bucket="${d.bucket || "customer-documents"}" title="${t("adminDownload")}">↗</a>
</div>`;
}
return html;
}
// ----- LEAD DIALOG (tabbed) -----
const leadTabOrder = ["general", "pricing", "documents", "notes"];
const leadTabLabels = {
general: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
pricing: () => getLang() === "de" ? t("adminTabPricing") : t("adminTabPricingEn"),
documents: () => getLang() === "de" ? t("adminTabDocuments") : t("adminTabDocumentsEn"),
notes: () => getLang() === "de" ? t("adminTabNotes") : t("adminTabNotesEn"),
};
async function openLead(id) {
const l = state.leads.find(x => x.id === id);
if (!l) return;
leadDialogTitle.textContent = `${l.name} · ${l.status}`;
// Build tabs
leadDialogTabs.innerHTML = leadTabOrder.map((tab, i) =>
`<button class="lead-tab${i === 0 ? " active" : ""}" data-lead-tab="${tab}">${leadTabLabels[tab]()}</button>`
).join("");
// Render first tab
await renderLeadTab("general", l);
leadDialog.showModal();
// Tab switching
leadDialogTabs.querySelectorAll(".lead-tab").forEach(btn => {
btn.addEventListener("click", () => {
leadDialogTabs.querySelectorAll(".lead-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderLeadTab(btn.dataset.leadTab, l);
});
});
leadDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
});
});
leadDialogClose.addEventListener("click", () => leadDialog.close(), { once: true });
}
async function renderLeadTab(tab, l) {
if (tab === "general") {
leadDialogBody.innerHTML = `
<dl class="kv">
<dt>${t("adminReceived")}</dt><dd>${fmtDate(l.created_at)}</dd>
<dt>E-Mail</dt><dd><a href="mailto:${attr(l.email)}">${esc(l.email)}</a></dd>
<dt>${t("adminPhone")}</dt><dd>${esc(l.phone || "—")}</dd>
<dt>Fahrzeug</dt><dd>${esc(l.vehicle_label || "—")}</dd>
<dt>${t("adminPeriod")}</dt><dd>${esc(l.date_from || "—")}${esc(l.date_to || "—")}${l.total_days ? " (" + l.total_days + " " + (getLang() === "de" ? t("bpfDays") : t("bpfDaysEn")) + ")" : ""}</dd>
<dt>Nachricht</dt><dd>${esc(l.message || "—")}</dd>
<dt>Quelle</dt><dd>${esc(l.source || "website")}</dd>
<dt>${t("adminStatus")}</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
<dt>${t("adminNote")}</dt><dd><textarea id="leadNote" rows="3" style="width:100%;resize:vertical;">${esc(l.admin_notes || "")}</textarea></dd>
</dl>`;
// Re-bind note save
const noteArea = document.querySelector("#leadNote");
const saveNoteBtn = document.createElement("button");
saveNoteBtn.className = "btn small";
saveNoteBtn.textContent = t("adminSave");
saveNoteBtn.addEventListener("click", async () => {
const { error } = await supabase.from("leads").update({ admin_notes: noteArea.value }).eq("id", l.id);
if (error) { alert(error.message); }
else { saveNoteBtn.textContent = "✓"; setTimeout(() => { saveNoteBtn.textContent = t("adminSave"); }, 1500); }
});
leadDialogBody.appendChild(saveNoteBtn);
} else if (tab === "pricing") {
const rental = rentalTypeMeta(l.rental_type);
const daily = l.daily_subtotal || 0;
const weekend = l.weekend_subtotal || 0;
const sub = l.subtotal_eur || 0;
const vat = l.vat_eur || 0;
const total = l.total_eur || 0;
const deposit = l.deposit_eur || 0;
const lang = getLang();
leadDialogBody.innerHTML = `
<div class="pricing-card">
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${l.weekday_count || 0} ×${l.daily_subtotal && l.weekday_count ? Math.round(daily / l.weekday_count) : "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${l.weekend_day_count || 0} ×${l.weekend_subtotal && l.weekend_day_count ? Math.round(weekend / l.weekend_day_count) : "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
</div>`;
} else if (tab === "documents") {
const docs = await loadLeadAttachments(l.id);
leadDialogBody.innerHTML = renderDocList(docs);
leadDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
});
});
} else if (tab === "notes") {
const lang = getLang();
leadDialogBody.innerHTML = `
<textarea id="leadNoteFull" rows="8" style="width:100%;resize:vertical;font-family:inherit;">${esc(l.admin_notes || "")}</textarea>
<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;">
<button class="btn small" id="saveNoteFull">${lang === "de" ? t("adminSave") : t("adminSaveEn")}</button>
</div>`;
document.querySelector("#saveNoteFull").addEventListener("click", async () => {
const { error } = await supabase.from("leads").update({ admin_notes: document.querySelector("#leadNoteFull").value }).eq("id", l.id);
if (error) { alert(error.message); }
else { document.querySelector("#saveNoteFull").textContent = "✓"; setTimeout(() => { document.querySelector("#saveNoteFull").textContent = t("adminSave"); }, 1500); }
});
}
// Footer buttons
if (l.is_active) {
leadDialogFooter.innerHTML = `
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button class="btn danger" id="dlgDisq">${t("adminReject")}</button>
<button class="btn" id="dlgQual">${t("adminQualify")}</button>
</div>`;
document.querySelector("#dlgQual")?.addEventListener("click", () => {
const note = document.querySelector("#leadNote")?.value || document.querySelector("#leadNoteFull")?.value || "";
qualifyLead(l.id, note);
});
document.querySelector("#dlgDisq")?.addEventListener("click", () => {
const note = document.querySelector("#leadNote")?.value || document.querySelector("#leadNoteFull")?.value || "";
disqualifyLead(l.id, note);
});
} else {
leadDialogFooter.innerHTML = `
<div style="display:flex;justify-content:flex-end;">
<button class="btn ghost" id="dlgReopen">${t("adminReopen")}</button>
</div>`;
document.querySelector("#dlgReopen")?.addEventListener("click", () => reopenLead(l.id));
}
}
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(), loadSalesOrders()]);
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(), loadSalesOrders()]);
renderActiveTab();
}
// =========================================================================
// CUSTOMERS
// =========================================================================
// ----- CUSTOMER LIFETIME VALUE -----
function calcCustomerLifetimeValue(customer) {
let total = 0;
for (const order of state.salesOrders) {
if (order.customer_id === customer.id) {
total += order.total_eur || 0;
}
}
return total;
}
// ----- CUSTOMER ATTACHMENTS -----
async function loadCustomerAttachments(customerId) {
const { data, error } = await supabase
.from("customer_attachments")
.select("*")
.eq("customer_id", customerId)
.order("created_at", { ascending: false });
if (error) { console.error(error); return []; }
return data || [];
}
async function loadSalesOrders() {
const { data, error } = await supabase
.from("sales_orders")
.select("*")
.order("created_at", { ascending: false });
if (error) { console.error(error); return []; }
state.salesOrders = data || [];
return state.salesOrders;
}
function getOrdersForCustomer(customerId) {
return state.salesOrders.filter((order) => order.customer_id === customerId);
}
function renderOrders() {
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
ordersTableBody.innerHTML = "";
for (const o of state.salesOrders) {
const rental = rentalTypeMeta(o.rental_type);
const total = o.total_eur || 0;
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
const cust = state.customers.find(c => c.id === o.customer_id);
const tr = document.createElement("tr");
tr.innerHTML = `
<td><strong>${esc(o.order_number || o.id.slice(0, 8))}</strong></td>
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
<td>${esc(o.vehicle_label || "—")}</td>
<td>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</td>
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
<td style="white-space:nowrap;"><span class="pill pill-${o.email_sent === 1 ? 'active' : o.email_sent === 2 ? 'disqualified' : 'new'}">${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'}</span></td>
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
ordersTableBody.appendChild(tr);
}
ordersTableBody.querySelectorAll("[data-open-order]").forEach(b => b.addEventListener("click", () => openOrder(b.dataset.openOrder)));
if (ordersBadge) ordersBadge.textContent = String(state.salesOrders.length);
}
// ----- ORDER DETAIL DIALOG -----
const orderDialog = document.querySelector("#orderDialog");
const orderDialogTitle = document.querySelector("#orderDialogTitle");
const orderDialogTabs = document.querySelector("#orderDialogTabs");
const orderDialogBody = document.querySelector("#orderDialogBody");
const orderDialogFooter = document.querySelector("#orderDialogFooter");
const orderDialogClose = document.querySelector("#orderDialogClose");
async function sendOrderEmailDirect(orderId) {
const sendBtn = orderDialogBody.querySelector("[data-manual-email-send]");
if (sendBtn) sendBtn.disabled = true;
const n8nUrl = (window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "http://localhost:55590") + "/webhook/manual-email-send";
try {
// Use urlencoded payload to avoid browser preflight/CORS issues with JSON headers.
const payload = new URLSearchParams({ sales_order_id: orderId });
const res = await fetch(n8nUrl, {
method: "POST",
body: payload,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
notify(t("emailSentToast"), 5000);
} catch (err) {
console.error("Webhook error:", err);
alert(`Email senden fehlgeschlagen: ${err?.message || err}`);
}
}
orderDialogBody.addEventListener("click", async (e) => {
const sendBtn = e.target.closest("[data-manual-email-send]");
if (!sendBtn) return;
e.preventDefault();
const orderId = sendBtn.dataset.orderId;
if (!orderId) return;
await sendOrderEmailDirect(orderId);
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === orderId);
if (fresh) await renderOrderTab("general", fresh, orderId);
});
const orderTabOrder = ["general", "pricing"];
const orderTabLabels = {
general: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
pricing: () => getLang() === "de" ? t("adminTabPricing") : t("adminTabPricingEn"),
};
async function openOrder(id) {
const o = state.salesOrders.find(x => x.id === id);
if (!o) return;
const cust = state.customers.find(c => c.id === o.customer_id);
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
// Build tabs
orderDialogTabs.innerHTML = orderTabOrder.map((tab, i) =>
`<button class="order-tab${i === 0 ? " active" : ""}" data-order-tab="${tab}">${orderTabLabels[tab]()}</button>`
).join("");
// Render first tab
await renderOrderTab("general", o, id);
orderDialog.showModal();
// Tab switching
orderDialogTabs.querySelectorAll(".order-tab").forEach(btn => {
btn.addEventListener("click", () => {
orderDialogTabs.querySelectorAll(".order-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderOrderTab(btn.dataset.orderTab, o, id);
});
});
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
}
async function renderOrderTab(tab, o, id) {
const rental = rentalTypeMeta(o.rental_type);
const isIndividuell = rental.type === "individuell";
const lang = getLang();
const cust = state.customers.find(c => c.id === o.customer_id);
const total = o.total_eur || 0;
const deposit = o.deposit_eur || 0;
const emailSent = o.email_sent || 0;
const emailSentText = emailSent === 1 ? '✓' : emailSent === 2 ? '✗' : '—';
const emailSentPillClass = emailSent === 1 ? 'active' : emailSent === 2 ? 'disqualified' : 'new';
const isEmailLocked = emailSent === 1;
if (tab === "general") {
// Load attachments for this order
const { data: attachments } = await supabase
.from("sales_order_attachments")
.select("*")
.eq("sales_order_id", id)
.order("created_at", { ascending: false });
const docs = attachments || [];
orderDialogBody.innerHTML = `
<dl class="kv">
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</dd>
<dt>${lang === "de" ? t("adminRentalType") : t("Rental type")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
</dl>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
<button class="btn small ${o.rental_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
</div>
<h4 style="margin:1.2rem 0 0.6rem;font-size:0.9rem;color:var(--muted);">${t("adminTabDocuments")}</h4>
${docs.length ? renderDocList(docs) : `<p class="muted" style="text-align:center;padding:1rem 0;">${t("adminNoDocuments")}</p>`}
<div style="margin-top:0.8rem;">
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.4rem;">
<div>${emailSent !== 1 ? `<button class="btn small" type="button" data-manual-email-send data-order-id="${o.id}" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button>` : ''}</div>
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
</div>
</div>`;
// Customer lookup link
orderDialogBody.querySelectorAll("[data-goto-cust]").forEach(a => {
a.addEventListener("click", (e) => {
e.preventDefault();
orderDialog.close();
openCustomer(a.dataset.gotoCust);
});
});
// Document open links
orderDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
});
});
// Toggle buttons
orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => {
btn.addEventListener("click", async () => {
await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle);
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === id);
if (fresh) await renderOrderTab("general", fresh, id);
});
});
// Dirty form tracking
let noteIsDirty = false;
const orderNoteEl = document.querySelector("#orderNote");
const originalNoteValue = o.private_notes || "";
const saveBtn = document.querySelector("#orderNoteSave");
if (orderNoteEl && saveBtn) {
saveBtn.classList.add("ghost");
orderNoteEl.addEventListener("input", () => {
noteIsDirty = orderNoteEl.value !== originalNoteValue;
if (noteIsDirty) {
saveBtn.classList.remove("ghost");
saveBtn.style.backgroundColor = "var(--accent-strong)";
saveBtn.style.color = "#fff";
saveBtn.textContent = t("adminSaveNotes") + " (unsaved)";
} else {
saveBtn.classList.add("ghost");
saveBtn.style.backgroundColor = "";
saveBtn.style.color = "";
saveBtn.textContent = t("adminSaveNotes");
}
});
}
// Save notes
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
if (ok) {
noteIsDirty = false;
document.querySelector("#orderNoteSave").textContent = "✓";
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
}
});
} else if (tab === "pricing") {
let daily = o.daily_subtotal || 0;
let weekend = o.weekend_subtotal || 0;
let sub = o.subtotal_eur || 0;
let vat = o.vat_eur || 0;
const days = o.total_days || 0;
const inclVatLabel = lang === "de" ? t("adminInclVat") : t("adminInclVatEn");
// For individuell: derive pricing from total (which includes VAT)
if (isIndividuell && total > 0) {
sub = Math.round(total / 1.2 * 100) / 100;
vat = Math.round((total - sub) * 100) / 100;
daily = sub; // all days counted as weekdays
weekend = 0;
}
const weekdayCount = isIndividuell ? days : (o.weekday_count || 0);
const weekendCount = isIndividuell ? 0 : (o.weekend_day_count || 0);
const perDay = daily && weekdayCount ? Math.round(daily / weekdayCount) : 0;
const perWeekend = weekend && weekendCount ? Math.round(weekend / weekendCount) : 0;
orderDialogBody.innerHTML = `
<div class="pricing-card">
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} ×${perDay || "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} ×${perWeekend || "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span>
<span>${isIndividuell && !isEmailLocked
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${total}" style="font-weight:600;width:120px;" />`
: '€ ' + total.toLocaleString("de-DE")
}</span>
</div>
${!isEmailLocked ? `<div class="price-row muted" id="inclVatHint"><span></span><span style="font-size:0.78rem;">${inclVatLabel}</span></div>` : ''}
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span>
<span>${isEmailLocked || !isIndividuell
? '€ ' + deposit.toLocaleString("de-DE")
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" />`
}</span>
</div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${days}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
</div>
${isIndividuell && !isEmailLocked ? `<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;"><button class="btn small" id="orderPricingSave">${t("adminSave")}</button></div>` : ''}`;
// Single save for both total + deposit
document.querySelector("#orderPricingSave")?.addEventListener("click", async () => {
const btn = document.querySelector("#orderPricingSave");
btn.disabled = true;
const totalInput = document.querySelector("#orderTotalInput");
const depositInput = document.querySelector("#orderDepositInput");
const errors = [];
if (totalInput) {
const { error } = await supabase.rpc("sales_order_set_total", { p_so_id: o.id, p_total_eur: +totalInput.value });
if (error) errors.push(error.message);
}
if (depositInput) {
const { error } = await supabase.rpc("sales_order_set_deposit", { p_so_id: o.id, p_deposit_eur: +depositInput.value });
if (error) errors.push(error.message);
}
if (errors.length) { alert(errors.join("\n")); btn.disabled = false; return; }
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === id);
if (fresh) await renderOrderTab("pricing", fresh, id);
});
}
orderDialogFooter.innerHTML = "";
}
async function toggleSalesOrderState(orderId, action) {
const rpcName = {
kaution: "sales_order_toggle_kaution",
rental: "sales_order_toggle_rental",
complete: "sales_order_toggle_complete",
}[action];
if (!rpcName) return;
const { error } = await supabase.rpc(rpcName, { p_so_id: orderId });
if (error) {
alert(error.message);
return;
}
await loadSalesOrders();
}
async function saveSalesOrderPrivateNotes(orderId, notes) {
const { error } = await supabase.rpc("sales_order_update_private_notes", {
p_so_id: orderId,
p_notes: notes,
});
if (error) {
alert(error.message);
return false;
}
await loadSalesOrders();
return true;
}
// ----- CUSTOMER DIALOG (tabbed) -----
const customerTabOrder = ["info", "documents", "salesOrders"];
const customerTabLabels = {
info: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
documents: () => getLang() === "de" ? t("adminTabDocuments") : t("adminTabDocumentsEn"),
salesOrders: () => getLang() === "de" ? t("adminTabOrderHistory") : t("adminTabOrderHistoryEn"),
};
async function openCustomer(id) {
const c = state.customers.find(x => x.id === id);
if (!c) return;
customerDialogTitle.textContent = `${c.name} · ${c.status}`;
// Build tabs
customerDialogTabs.innerHTML = customerTabOrder.map((tab, i) =>
`<button class="customer-tab${i === 0 ? " active" : ""}" data-customer-tab="${tab}">${customerTabLabels[tab]()}</button>`
).join("");
// Render first tab
await renderCustomerTab("info", c);
customerDialog.showModal();
// Tab switching
customerDialogTabs.querySelectorAll(".customer-tab").forEach(btn => {
btn.addEventListener("click", () => {
customerDialogTabs.querySelectorAll(".customer-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderCustomerTab(btn.dataset.customerTab, c);
});
});
customerDialogClose.addEventListener("click", () => customerDialog.close(), { once: true });
}
async function renderCustomerTab(tab, c) {
if (tab === "info") {
const lang = getLang();
const sourceLead = state.leads.find((lead) => lead.id === c.lead_id);
customerDialogBody.innerHTML = `
<dl class="kv">
<dt>${lang === "de" ? "Name" : "Name"}</dt><dd><input id="custName" value="${attr(c.name || "")}" style="width:100%"></dd>
<dt>E-Mail</dt><dd><input id="custEmail" value="${attr(c.email || "")}" style="width:100%"></dd>
<dt>${lang === "de" ? t("adminPhone") : "Phone"}</dt><dd><input id="custPhone" value="${attr(c.phone || "")}" style="width:100%"></dd>
<dt>${lang === "de" ? t("adminFirstContacted") : t("adminFirstContactedEn")}</dt><dd>${fmtDate(c.first_contacted_at)}</dd>
<dt>${lang === "de" ? "Status" : "Status"}</dt><dd><span class="pill pill-${esc(c.status)}">${esc(c.status)}</span></dd>
<dt>${lang === "de" ? "Website-Nachricht" : "Website message"}</dt><dd>${esc(sourceLead?.message || "—")}</dd>
<dt>${lang === "de" ? t("adminPrivateNotes") : t("adminPrivateNotesEn")}</dt><dd><textarea id="custPrivateNote" rows="5" style="width:100%;resize:vertical;">${esc(c.private_notes || "")}</textarea></dd>
</dl>`;
const saveBtn = document.createElement("button");
saveBtn.className = "btn small";
saveBtn.textContent = t("adminSave");
saveBtn.addEventListener("click", async () => {
const payload = {
name: document.querySelector("#custName").value,
email: document.querySelector("#custEmail").value,
phone: document.querySelector("#custPhone").value,
};
const { error } = await supabase.from("customers").update(payload).eq("id", c.id);
if (error) {
alert(error.message);
return;
}
const { error: privateErr } = await supabase.rpc("customer_update_private_notes", {
p_customer_id: c.id,
p_notes: document.querySelector("#custPrivateNote").value,
});
if (privateErr) {
alert(privateErr.message);
return;
}
saveBtn.textContent = "✓";
await Promise.all([loadCustomers(), loadSalesOrders()]);
setTimeout(() => { saveBtn.textContent = t("adminSave"); }, 1500);
});
customerDialogBody.appendChild(saveBtn);
} else if (tab === "documents") {
const orders = getOrdersForCustomer(c.id);
const orderLabelById = new Map(orders.map((order) => [order.id, order.order_number || order.id.slice(0, 8)]));
const custDocs = await loadCustomerAttachments(c.id);
let leadDocs = [];
if (c.lead_id) {
const leadDocsRaw = await loadLeadAttachments(c.lead_id);
const custPaths = new Set(custDocs.map(d => d.file_path));
leadDocs = leadDocsRaw.filter(d => !custPaths.has(d.file_path));
}
let html = "";
if (leadDocs.length) {
html += `<h4 style="margin:0 0 0.8rem;font-size:0.9rem;color:var(--muted);">${c.lead_id ? "─ Von Lead übernommen" : ""}</h4>`;
html += renderDocList(leadDocs, orderLabelById);
}
if (custDocs.length) {
html += `<h4 style="margin:1.2rem 0 0.8rem;font-size:0.9rem;color:var(--muted);">─ Direkt hochgeladen</h4>`;
html += renderDocList(custDocs, orderLabelById);
}
if (!leadDocs.length && !custDocs.length) {
html = `<p class="muted" style="text-align:center;padding:1.5rem 0;">${getLang() === "de" ? t("adminNoDocuments") : t("adminNoDocumentsEn")}</p>`;
}
customerDialogBody.innerHTML = html;
customerDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
});
});
} else if (tab === "salesOrders") {
const orders = getOrdersForCustomer(c.id);
const lang = getLang();
let html = "";
if (orders.length) {
for (const o of orders) {
const rental = rentalTypeMeta(o.rental_type);
const total = o.total_eur || 0;
html += `
<div class="pricing-card" style="margin-bottom:0.9rem;">
<div class="price-row total" style="margin-top:0;padding-top:0;border-top:none;">
<span>${esc(o.order_number || "SO")}</span>
<span>${total > 0 ? "€ " + total.toLocaleString("de-DE") : "—"}</span>
</div>
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
<button class="btn small ${o.rental_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
</div>
<div style="margin-top:0.8rem;">
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
<textarea rows="3" style="width:100%;resize:vertical;" data-so-note="${o.id}">${esc(o.private_notes || "")}</textarea>
<div style="display:flex;justify-content:flex-end;margin-top:0.4rem;">
<button class="btn small" data-so-save-note="${o.id}">${t("adminSaveNotes")}</button>
</div>
</div>
</div>`;
}
const lifetime = calcCustomerLifetimeValue(c);
html += `<div class="pricing-card" style="margin-top:0.5rem;">
<div class="price-row total"><span>${lang === "de" ? t("adminLifetimeValue") : t("adminLifetimeValueEn")}</span><span>€ ${lifetime.toLocaleString("de-DE")}</span></div>
</div>`;
} else {
html = `<p class="muted" style="text-align:center;padding:2rem 0;">${t("adminNoOrders")}</p>`;
}
customerDialogBody.innerHTML = html;
customerDialogBody.querySelectorAll("[data-so-toggle]").forEach((btn) => {
btn.addEventListener("click", async () => {
await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle);
await renderCustomerTab("salesOrders", c);
});
});
customerDialogBody.querySelectorAll("[data-so-save-note]").forEach((btn) => {
btn.addEventListener("click", async () => {
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
if (ok) {
btn.classList.remove("ghost");
btn.style.backgroundColor = "";
btn.style.color = "";
btn.textContent = "✓";
setTimeout(() => {
btn.textContent = t("adminSaveNotes");
}, 1500);
}
});
});
customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => {
const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`);
const originalValue = noteEl.value;
noteEl.addEventListener("input", () => {
const isDirty = noteEl.value !== originalValue;
if (isDirty) {
btn.classList.remove("ghost");
btn.style.backgroundColor = "var(--accent-strong)";
btn.style.color = "#fff";
btn.textContent = "Speichern (unsaved)";
} else {
btn.classList.add("ghost");
btn.style.backgroundColor = "";
btn.style.color = "";
btn.textContent = t("adminSaveNotes");
}
});
});
}
// Footer
customerDialogFooter.innerHTML = `
<div style="display:flex;justify-content:flex-end;">
<button class="btn ghost" id="dlgCustToggle" data-status="${c.status}">
${c.status === "active" ? t("adminSetInactive") : t("adminSetActive")}
</button>
</div>`;
document.querySelector("#dlgCustToggle")?.addEventListener("click", async () => {
const next = c.status === "active" ? "inactive" : "active";
const { error } = await supabase.from("customers").update({ status: next }).eq("id", c.id);
if (error) { alert(error.message); }
else {
customerDialog.close();
await loadCustomers();
renderCustomers();
}
});
}
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 lifetime = calcCustomerLifetimeValue(c);
const lifetimeStr = lifetime > 0 ? "€ " + lifetime.toLocaleString("de-DE") : "—";
const leadShort = c.lead_id?.slice(0, 8) || "—";
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>${c.lead_id ? `<a href="#" class="link-lead" data-goto-lead="${c.lead_id}"><code class="muted">${esc(leadShort)}</code></a>` : `<code class="muted">—</code>`}</td>
<td style="font-weight:600;color:var(--accent-strong);">${lifetimeStr}</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-open-cust="${c.id}">${t("adminDetails")}</button>
<button class="btn small ghost" data-toggle="${c.id}" data-status="${c.status}">
${c.status === "active" ? t("adminSetInactive") : t("adminSetActive")}
</button>
</td>`;
customersTableBody.appendChild(tr);
}
customersTableBody.querySelectorAll("[data-open-cust]").forEach(b => b.addEventListener("click", () => openCustomer(b.dataset.openCust)));
customersTableBody.querySelectorAll("[data-goto-lead]").forEach(b => b.addEventListener("click", (e) => {
e.preventDefault();
openLead(b.dataset.gotoLead);
}));
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);
if (ordersBadge) ordersBadge.textContent = String(state.salesOrders.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: "sales_orders" }, async () => { await loadSalesOrders(); if (activeTab === "customers") renderCustomers(); if (activeTab === "orders") renderOrders(); })
.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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); }
function attr(s) { return esc(s); }
function normalizeRentalType(rawType) {
const value = String(rawType ?? "").trim().toLowerCase();
if (!value) return "weekend";
if (value === "individual" || value === "custom") return "individuell";
if (value === "day" || value === "daily" || value === "1 tag" || value === "1_tag" || value === "single_day") return "single_day";
if (value === "weekend") return "weekend";
return value;
}
function rentalTypeMeta(rawType) {
const type = normalizeRentalType(rawType);
const lang = getLang();
if (type === "single_day") return { type, label: lang === "de" ? "1 Tag" : "1 Day" };
if (type === "individuell") return { type, label: lang === "de" ? "individuell" : "individual" };
if (type === "weekend") return { type, label: "weekend" };
return { type: "weekend", label: type };
}
function fmtDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString("de-AT", { dateStyle: "short", timeStyle: "short" });
}
if (langToggle) {
langToggle.addEventListener("click", () => {
const current = getLang();
setLang(current === "de" ? "en" : "de");
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
// Re-render JS injected text correctly
if (state.vehicles) renderVehicles();
if (state.leads) renderLeads();
if (state.customers) renderCustomers();
});
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
}
// Refresh displayed data when any dialog closes
function onDialogClose() {
Promise.all([loadLeads(), loadCustomers(), loadSalesOrders()]).then(renderActiveTab);
}
leadDialog.addEventListener("close", onDialogClose);
customerDialog.addEventListener("close", onDialogClose);
orderDialog.addEventListener("close", onDialogClose);
// =========================================================================
// SETTINGS
// =========================================================================
const heroPreview = document.querySelector("#heroPreview");
const heroImageInput = document.querySelector("#heroImageInput");
const heroFeedback = document.querySelector("#heroFeedback");
const mietvertragStatus = document.querySelector("#mietvertragStatus");
const mietvertragInput = document.querySelector("#mietvertragInput");
const mietvertragFeedback = document.querySelector("#mietvertragFeedback");
async function renderSettings() {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
const url = data?.value || "/images/ferrari-main-car-mobile.jpg";
heroPreview.style.backgroundImage = `url('${url}')`;
// Mietvertrag template status
const { data: tplData } = await supabase.from("site_settings").select("value").eq("key", "mietvertrag_template_path").single();
const tplPath = tplData?.value;
if (tplPath) {
mietvertragStatus.textContent = "✓ " + tplPath.split("/").pop();
mietvertragStatus.style.color = "var(--success, green)";
} else {
mietvertragStatus.textContent = t("adminMietvertragEmpty") || "Keine Vorlage hochgeladen.";
mietvertragStatus.style.color = "var(--muted)";
}
}
heroImageInput.addEventListener("change", async () => {
const file = heroImageInput.files?.[0];
if (!file) return;
heroFeedback.className = "form-feedback";
heroFeedback.textContent = "Uploading...";
try {
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const path = `site/hero.${ext}`;
const { error: upErr } = await supabase.storage
.from("vehicle-photos")
.upload(path, file, { contentType: file.type, upsert: true });
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
const publicUrl = pub.publicUrl;
// Save to site_settings
const { error: dbErr } = await supabase
.from("site_settings")
.upsert({ key: "hero_image_url", value: publicUrl, updated_at: new Date().toISOString() }, { onConflict: "key" });
if (dbErr) throw dbErr;
heroPreview.style.backgroundImage = `url('${publicUrl}')`;
heroFeedback.className = "form-feedback";
heroFeedback.textContent = "Gespeichert.";
} catch (err) {
heroFeedback.className = "form-feedback error";
heroFeedback.textContent = err.message || String(err);
}
});
mietvertragInput.addEventListener("change", async () => {
const file = mietvertragInput.files?.[0];
if (!file) return;
mietvertragFeedback.className = "form-feedback";
mietvertragFeedback.textContent = "Uploading...";
try {
const path = `mietvertrag/vorlage.docx`;
const { error: upErr } = await supabase.storage
.from("document-templates")
.upload(path, file, { contentType: file.type, upsert: true });
if (upErr) throw upErr;
// Save path to site_settings
const { error: dbErr } = await supabase
.from("site_settings")
.upsert({ key: "mietvertrag_template_path", value: path, updated_at: new Date().toISOString() }, { onConflict: "key" });
if (dbErr) throw dbErr;
mietvertragStatus.textContent = "✓ " + file.name;
mietvertragStatus.style.color = "var(--success, green)";
mietvertragFeedback.className = "form-feedback";
mietvertragFeedback.textContent = "Gespeichert.";
} catch (err) {
mietvertragFeedback.className = "form-feedback error";
mietvertragFeedback.textContent = err.message || String(err);
}
});
bootstrap();