feat: update upload functionality and permissions for document handling
- Removed the `upsert` option from the file upload in `uploadDoc` function to prevent unintended overwrites. - Enhanced German translations in `i18n.js` for better clarity and consistency in the admin interface. - Added new CSS styles for link interactions to improve user experience in `styles.css`. - Updated Supabase SQL migration to grant additional permissions for anonymous users to insert and update storage objects, ensuring proper functionality during the booking flow. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+37
-2
@@ -27,7 +27,7 @@
|
||||
<button class="btn" type="submit">Anmelden</button>
|
||||
<p class="form-feedback error" id="loginError"></p>
|
||||
<p style="color:var(--muted);font-size:0.82rem;text-align:center;">
|
||||
Only admins. Self-registration is disabled.
|
||||
Nur für Administratoren. Selbstregistrierung ist deaktiviert.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="admin-bar">
|
||||
<h1>MC Cars · Admin</h1>
|
||||
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;">
|
||||
<a href="index.html" class="btn ghost small" data-i18n="adminNavWebsite">Website</a>
|
||||
<a id="websiteLink" href="/index.html" target="_blank" class="btn ghost small" data-i18n="adminNavWebsite">Website</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln" style="margin-left:auto;">EN</button>
|
||||
|
||||
<span id="adminWho" style="color:var(--muted);font-size:0.85rem;margin-left:1rem;"></span>
|
||||
@@ -74,6 +74,7 @@
|
||||
<div class="admin-tabs" role="tablist">
|
||||
<button class="tab active" data-tab="leads" role="tab"><span data-i18n="adminLeads">Leads</span> <span id="leadsBadge" class="tab-badge">0</span></button>
|
||||
<button class="tab" data-tab="customers" role="tab"><span data-i18n="adminCustomers">Kunden</span> <span id="customersBadge" class="tab-badge">0</span></button>
|
||||
<button class="tab" data-tab="orders" role="tab"><span data-i18n="adminTabOrderHistory">Bestellungen</span> <span id="ordersBadge" class="tab-badge">0</span></button>
|
||||
<button class="tab" data-tab="vehicles" role="tab" data-i18n="adminVehicles">Fahrzeuge</button>
|
||||
</div>
|
||||
|
||||
@@ -128,6 +129,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SALES ORDERS -->
|
||||
<div class="tab-panel" id="tab-orders" style="display:none;">
|
||||
<div class="panel">
|
||||
<h2 data-i18n="adminTabOrderHistory">Bestellungen</h2>
|
||||
<table class="admin-table" id="ordersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th>
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||
<th>Kaution</th>
|
||||
<th>Miete</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p id="ordersEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Keine Bestellungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VEHICLES -->
|
||||
<div class="tab-panel" id="tab-vehicles" style="display:none;">
|
||||
<div class="admin-grid">
|
||||
@@ -215,6 +240,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Order detail dialog -->
|
||||
<dialog id="orderDialog">
|
||||
<div class="dialog-head">
|
||||
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
||||
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-body" id="orderDialogBody"></div>
|
||||
<div class="dialog-footer" id="orderDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<!-- Lead detail / qualify dialog (tabbed) -->
|
||||
<dialog id="leadDialog">
|
||||
<div class="dialog-head">
|
||||
|
||||
+287
-85
@@ -29,6 +29,9 @@ 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");
|
||||
@@ -59,6 +62,7 @@ const state = {
|
||||
leadView: "active", // "active" | "inactive"
|
||||
leads: [],
|
||||
customers: [],
|
||||
salesOrders: [],
|
||||
vehicles: [],
|
||||
vehicleMap: new Map(),
|
||||
currentPhotoPath: null,
|
||||
@@ -153,8 +157,8 @@ async function onAuthenticated(user) {
|
||||
|
||||
async function enterAdmin() {
|
||||
show("admin");
|
||||
await Promise.all([loadVehicles(), loadLeads(), loadCustomers()]);
|
||||
renderActiveTab();
|
||||
await Promise.all([loadVehicles(), loadLeads(), loadCustomers(), loadSalesOrders()]);
|
||||
setActiveTab(getTabFromHash());
|
||||
attachRealtime();
|
||||
}
|
||||
|
||||
@@ -171,18 +175,36 @@ 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"),
|
||||
};
|
||||
let activeTab = "leads";
|
||||
tabButtons.forEach(btn => btn.addEventListener("click", () => {
|
||||
activeTab = btn.dataset.tab;
|
||||
tabButtons.forEach(b => b.classList.toggle("active", b === btn));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -410,13 +432,13 @@ async function loadLeadAttachments(leadId) {
|
||||
return data || [];
|
||||
}
|
||||
|
||||
async function getAttachmentUrl(attachment) {
|
||||
try {
|
||||
const { data: pub } = supabase.storage
|
||||
.from(attachment.bucket)
|
||||
.getPublicUrl(attachment.file_path);
|
||||
return pub?.publicUrl || null;
|
||||
} catch { return null; }
|
||||
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) {
|
||||
@@ -436,7 +458,7 @@ function docKindLabel(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDocList(docs) {
|
||||
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>`;
|
||||
@@ -450,9 +472,10 @@ function renderDocList(docs) {
|
||||
<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-download="${d.file_path}" title="${t("adminDownload")}">⬇</a>
|
||||
<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;
|
||||
@@ -491,13 +514,10 @@ async function openLead(id) {
|
||||
});
|
||||
});
|
||||
|
||||
// Download handlers
|
||||
leadDialogBody.querySelectorAll("[data-download]").forEach(btn => {
|
||||
leadDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const path = btn.dataset.download;
|
||||
const { data: pub } = supabase.storage.from("customer-documents").getPublicUrl(path);
|
||||
if (pub?.publicUrl) window.open(pub.publicUrl, "_blank");
|
||||
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,6 +533,7 @@ async function renderLeadTab(tab, l) {
|
||||
<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>
|
||||
@@ -550,13 +571,10 @@ async function renderLeadTab(tab, l) {
|
||||
} else if (tab === "documents") {
|
||||
const docs = await loadLeadAttachments(l.id);
|
||||
leadDialogBody.innerHTML = renderDocList(docs);
|
||||
// Re-bind downloads
|
||||
leadDialogBody.querySelectorAll("[data-download]").forEach(btn => {
|
||||
leadDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const path = btn.dataset.download;
|
||||
const { data: pub } = supabase.storage.from("customer-documents").getPublicUrl(path);
|
||||
if (pub?.publicUrl) window.open(pub.publicUrl, "_blank");
|
||||
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
|
||||
});
|
||||
});
|
||||
} else if (tab === "notes") {
|
||||
@@ -602,7 +620,7 @@ async function qualifyLead(id, 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()]);
|
||||
await Promise.all([loadLeads(), loadCustomers(), loadSalesOrders()]);
|
||||
renderActiveTab();
|
||||
}
|
||||
async function disqualifyLead(id, notes = "") {
|
||||
@@ -618,7 +636,7 @@ async function reopenLead(id) {
|
||||
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()]);
|
||||
await Promise.all([loadLeads(), loadCustomers(), loadSalesOrders()]);
|
||||
renderActiveTab();
|
||||
}
|
||||
|
||||
@@ -628,9 +646,9 @@ async function reopenLead(id) {
|
||||
// ----- CUSTOMER LIFETIME VALUE -----
|
||||
function calcCustomerLifetimeValue(customer) {
|
||||
let total = 0;
|
||||
for (const l of state.leads) {
|
||||
if (l.email.toLowerCase() === customer.email.toLowerCase()) {
|
||||
total += l.total_eur || 0;
|
||||
for (const order of state.salesOrders) {
|
||||
if (order.customer_id === customer.id) {
|
||||
total += order.total_eur || 0;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -647,23 +665,166 @@ async function loadCustomerAttachments(customerId) {
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ----- ORDER HISTORY -----
|
||||
async function loadOrderHistory(email) {
|
||||
async function loadSalesOrders() {
|
||||
const { data, error } = await supabase
|
||||
.from("leads")
|
||||
.from("sales_orders")
|
||||
.select("*")
|
||||
.eq("email", email)
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) { console.error(error); return []; }
|
||||
return data || [];
|
||||
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", "orderHistory"];
|
||||
const customerTabOrder = ["info", "documents", "salesOrders"];
|
||||
const customerTabLabels = {
|
||||
info: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
|
||||
documents: () => getLang() === "de" ? t("adminTabDocuments") : t("adminTabDocumentsEn"),
|
||||
orderHistory: () => getLang() === "de" ? t("adminTabOrderHistory") : t("adminTabOrderHistory"),
|
||||
salesOrders: () => getLang() === "de" ? t("adminTabOrderHistory") : t("adminTabOrderHistoryEn"),
|
||||
};
|
||||
|
||||
async function openCustomer(id) {
|
||||
@@ -690,106 +851,132 @@ async function openCustomer(id) {
|
||||
});
|
||||
});
|
||||
|
||||
// Download handlers
|
||||
customerDialogBody.querySelectorAll("[data-download]").forEach(btn => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const path = btn.dataset.download;
|
||||
const { data: pub } = supabase.storage.from("customer-documents").getPublicUrl(path);
|
||||
if (pub?.publicUrl) window.open(pub.publicUrl, "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
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><strong>${esc(c.name)}</strong></dd>
|
||||
<dt>E-Mail</dt><dd><a href="mailto:${attr(c.email)}">${esc(c.email)}</a></dd>
|
||||
<dt>${lang === "de" ? t("adminPhone") : "Phone"}</dt><dd>${esc(c.phone || "—")}</dd>
|
||||
<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" ? t("adminNote") : t("adminNoteEn")}</dt><dd><textarea id="custNote" rows="4" style="width:100%;resize:vertical;">${esc(c.notes || "")}</textarea></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 noteArea = document.querySelector("#custNote");
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.className = "btn small";
|
||||
saveBtn.textContent = t("adminSave");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const { error } = await supabase.from("customers").update({ notes: noteArea.value }).eq("id", c.id);
|
||||
if (error) { alert(error.message); }
|
||||
else { saveBtn.textContent = "✓"; setTimeout(() => { saveBtn.textContent = t("adminSave"); }, 1500); }
|
||||
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") {
|
||||
// Show customer_attachments + inherited lead_attachments
|
||||
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);
|
||||
// Filter out docs already in custDocs (by file_path)
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
// Re-bind downloads
|
||||
customerDialogBody.querySelectorAll("[data-download]").forEach(btn => {
|
||||
customerDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const path = btn.dataset.download;
|
||||
const { data: pub } = supabase.storage.from("customer-documents").getPublicUrl(path);
|
||||
if (pub?.publicUrl) window.open(pub.publicUrl, "_blank");
|
||||
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
|
||||
});
|
||||
});
|
||||
} else if (tab === "orderHistory") {
|
||||
const orders = await loadOrderHistory(c.email);
|
||||
} else if (tab === "salesOrders") {
|
||||
const orders = getOrdersForCustomer(c.id);
|
||||
const lang = getLang();
|
||||
let html = "";
|
||||
if (orders.length) {
|
||||
html += `<table class="admin-table" style="margin-bottom:1rem;">
|
||||
<thead><tr>
|
||||
<th>${lang === "de" ? "Eingang" : "Received"}</th>
|
||||
<th>Fahrzeug</th>
|
||||
<th>${lang === "de" ? "Zeitraum" : "Period"}</th>
|
||||
<th style="text-align:right;">${lang === "de" ? t("adminTotalPrice") : t("adminTotalPrice")}</th>
|
||||
<th>${lang === "de" ? "Status" : "Status"}</th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
for (const o of orders) {
|
||||
const total = o.total_eur || 0;
|
||||
html += `<tr>
|
||||
<td>${fmtDate(o.created_at)}</td>
|
||||
<td>${esc(o.vehicle_label || "—")}</td>
|
||||
<td>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</td>
|
||||
<td style="text-align:right;font-weight:600;color:var(--accent-strong);">${total > 0 ? "€ " + total.toLocaleString("de-DE") : "—"}</td>
|
||||
<td><span class="pill pill-${esc(o.status)}">${esc(o.status)}</span></td>
|
||||
</tr>`;
|
||||
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>`;
|
||||
}
|
||||
html += `</tbody></table>`;
|
||||
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;">Keine Buchungen gefunden.</p>`;
|
||||
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
|
||||
@@ -827,12 +1014,13 @@ function renderCustomers() {
|
||||
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><code class="muted">${esc(c.lead_id?.slice(0, 8) || "—")}</code></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;">
|
||||
@@ -844,6 +1032,10 @@ function renderCustomers() {
|
||||
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";
|
||||
@@ -858,6 +1050,7 @@ 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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -869,6 +1062,7 @@ function attachRealtime() {
|
||||
.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();
|
||||
}
|
||||
@@ -898,4 +1092,12 @@ if (langToggle) {
|
||||
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);
|
||||
|
||||
bootstrap();
|
||||
|
||||
+1
-1
@@ -494,7 +494,7 @@ async function uploadDoc(leadId, file, kind) {
|
||||
const path = `${leadId}/${kind}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from("customer-documents")
|
||||
.upload(path, file, { contentType: file.type, upsert: true });
|
||||
.upload(path, file, { contentType: file.type });
|
||||
if (upErr) { console.error("Upload failed:", upErr); return; }
|
||||
await supabase.from("lead_attachments").insert({
|
||||
lead_id: leadId,
|
||||
|
||||
+29
-3
@@ -180,10 +180,23 @@ export const translations = {
|
||||
adminTabDocumentsEn: "Documents",
|
||||
adminTabNotes: "Notiz",
|
||||
adminTabNotesEn: "Notes",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminTabOrderHistory: "Bestellungen",
|
||||
adminTabOrderHistoryEn: "Sales Orders",
|
||||
adminPrivateNotes: "Private Notizen",
|
||||
adminPrivateNotesEn: "Private Notes",
|
||||
adminSaveNotes: "Notizen speichern",
|
||||
adminSaveNotesEn: "Save notes",
|
||||
adminNoOrders: "Keine Buchungen gefunden.",
|
||||
adminNoOrdersEn: "No bookings found.",
|
||||
adminKautionPending: "Kaution ausstehend",
|
||||
adminKautionPaid: "Kaution ✓",
|
||||
adminRentalPending: "Miete ausstehend",
|
||||
adminRentalPaid: "Miete ✓",
|
||||
adminCompletePending: "Abgeschlossen offen",
|
||||
adminCompleteDone: "Abgeschlossen ✓",
|
||||
adminLifetimeValue: "Gesamtwert aller Buchungen",
|
||||
adminLifetimeValueEn: "Lifetime value",
|
||||
adminDownload: "Download",
|
||||
adminDownload: "Herunterladen",
|
||||
adminNoDocuments: "Keine Dokumente hochgeladen",
|
||||
adminNoDocumentsEn: "No documents uploaded",
|
||||
adminIdDoc: "Ausweis / Führerschein",
|
||||
@@ -395,7 +408,20 @@ export const translations = {
|
||||
adminTabDocumentsEn: "Dokumente",
|
||||
adminTabNotes: "Notes",
|
||||
adminTabNotesEn: "Notiz",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminTabOrderHistory: "Sales Orders",
|
||||
adminTabOrderHistoryEn: "Bestellungen",
|
||||
adminPrivateNotes: "Private Notes",
|
||||
adminPrivateNotesEn: "Private Notizen",
|
||||
adminSaveNotes: "Save notes",
|
||||
adminSaveNotesEn: "Notizen speichern",
|
||||
adminNoOrders: "No bookings found.",
|
||||
adminNoOrdersEn: "Keine Buchungen gefunden.",
|
||||
adminKautionPending: "Deposit pending",
|
||||
adminKautionPaid: "Deposit ✓",
|
||||
adminRentalPending: "Rental pending",
|
||||
adminRentalPaid: "Rental ✓",
|
||||
adminCompletePending: "Complete open",
|
||||
adminCompleteDone: "Complete ✓",
|
||||
adminLifetimeValue: "Lifetime value",
|
||||
adminLifetimeValueEn: "Gesamtwert aller Buchungen",
|
||||
adminDownload: "Download",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index admin.html;
|
||||
|
||||
# Never cache config.js / html so runtime config updates take effect.
|
||||
location = /config.js { add_header Cache-Control "no-store"; try_files $uri =404; }
|
||||
location ~* \.html$ { add_header Cache-Control "no-store"; try_files $uri =404; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /admin.html;
|
||||
}
|
||||
|
||||
# Static assets: images/fonts can be cached, JS/CSS must revalidate.
|
||||
location ~* \.(?:jpg|jpeg|png|webp|svg|ico|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location ~* \.(?:css|js)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -900,6 +900,9 @@ table.admin-table tbody tr:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.link-lead { text-decoration: none; cursor: pointer; }
|
||||
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
||||
|
||||
.admin-form { display: grid; gap: 1rem; }
|
||||
.admin-form label { display: grid; gap: 0.3rem; font-size: 0.85rem; color: var(--muted); transition: color 0.2s; }
|
||||
.admin-form label:focus-within { color: var(--accent-strong); }
|
||||
|
||||
Reference in New Issue
Block a user