Files
mc_cars_gmbh_infraestructure/frontend/admin.js
T

1191 lines
56 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,
};
// =========================================================================
// 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.max_daily_km.value = v.max_daily_km || 150;
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
vehicleForm.max_km_weekend.value = v.max_km_weekend || '';
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.max_daily_km.value = 150;
vehicleForm.weekend_price_eur.value = 0;
vehicleForm.kaution_eur.value = 5000;
vehicleForm.max_km_weekend.value = '';
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,
max_daily_km: +fd.get("max_daily_km") || 150,
kaution_eur: +fd.get("kaution_eur") || 5000,
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null,
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 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="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 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.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 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>`;
} 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 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="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;"><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 orderDialogBody = document.querySelector("#orderDialogBody");
const orderDialogFooter = document.querySelector("#orderDialogFooter");
const orderDialogClose = document.querySelector("#orderDialogClose");
async function openOrder(id) {
const o = state.salesOrders.find(x => x.id === id);
if (!o) return;
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;
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
// 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>${t("adminTotalLabel")}</dt><dd style="font-weight:600;">€ ${total.toLocaleString("de-DE")}</dd>
<dt>${t("adminDepositLabel")}</dt><dd>€ ${deposit.toLocaleString("de-DE")}</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:flex-end;margin-top:0.4rem;">
<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 openOrder(id); // re-render
});
});
// Save notes
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
if (ok) {
document.querySelector("#orderNoteSave").textContent = "✓";
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
}
});
orderDialogFooter.innerHTML = "";
orderDialog.showModal();
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
}
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 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 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.textContent = "✓";
setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500);
}
});
});
}
// 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 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();