1437 lines
69 KiB
JavaScript
1437 lines
69 KiB
JavaScript
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;
|
||
}
|
||
showAdminToast(message, duration);
|
||
}
|
||
|
||
function showAdminToast(message, duration = 3000) {
|
||
let toast = document.querySelector("#toast");
|
||
if (!toast) {
|
||
toast = document.createElement("div");
|
||
toast.id = "toast";
|
||
toast.className = "toast";
|
||
toast.setAttribute("role", "status");
|
||
toast.setAttribute("aria-live", "polite");
|
||
document.body.appendChild(toast);
|
||
}
|
||
|
||
toast.textContent = message;
|
||
toast.classList.add("show");
|
||
setTimeout(() => {
|
||
toast.classList.remove("show");
|
||
}, 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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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();
|