feat: rework admin portal with pricing breakdown and document management
- Added pricing snapshot columns to the leads table: daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count. - Updated create_lead RPC to accept and store new pricing parameters. - Enhanced frontend app.js to compute and send pricing details during lead creation. - Introduced new UI elements in admin.html for displaying pricing and documents in a tabbed dialog. - Updated i18n.js with new translation keys for pricing and document management. - Improved styles in styles.css for new dialog components and pricing cards. - Added migration script 06-admin-pricing-documents.sql for database schema changes.
This commit is contained in:
@@ -20,6 +20,7 @@ services:
|
||||
- ./supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
|
||||
- ./supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
|
||||
- ./supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
|
||||
- ./supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
@@ -28,4 +29,4 @@ services:
|
||||
web:
|
||||
volumes:
|
||||
- ./frontend:/usr/share/nginx/html
|
||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
@@ -213,6 +213,7 @@ services:
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -234,6 +235,7 @@ services:
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/03-booking-flow.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/04-kaution-weekend-km.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/05-create-lead-rpc.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/06-admin-pricing-documents.sql
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
|
||||
+16
-1
@@ -94,6 +94,7 @@
|
||||
<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 data-i18n="adminStatus">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -116,6 +117,7 @@
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminPhone">Telefon</th>
|
||||
<th data-i18n="adminSourceLead">Quelle (Lead)</th>
|
||||
<th data-i18n="adminLifetimeValueCol">Gesamtwert</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -213,13 +215,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lead detail / qualify dialog -->
|
||||
<!-- Lead detail / qualify dialog (tabbed) -->
|
||||
<dialog id="leadDialog">
|
||||
<div class="dialog-head">
|
||||
<h3 id="leadDialogTitle" style="margin:0;">Lead</h3>
|
||||
<button class="dialog-close" id="leadDialogClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-tabs" id="leadDialogTabs" role="tablist"></div>
|
||||
<div class="dialog-body" id="leadDialogBody"></div>
|
||||
<div class="dialog-footer" id="leadDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<!-- Customer detail dialog (tabbed) -->
|
||||
<dialog id="customerDialog">
|
||||
<div class="dialog-head">
|
||||
<h3 id="customerDialogTitle" style="margin:0;">Kunde</h3>
|
||||
<button class="dialog-close" id="customerDialogClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-tabs" id="customerDialogTabs" role="tablist"></div>
|
||||
<div class="dialog-body" id="customerDialogBody"></div>
|
||||
<div class="dialog-footer" id="customerDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="admin.js"></script>
|
||||
|
||||
+395
-23
@@ -33,6 +33,15 @@ 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");
|
||||
@@ -363,12 +372,15 @@ function renderLeads() {
|
||||
leadsEmpty.style.display = rows.length ? "none" : "block";
|
||||
leadsTableBody.innerHTML = "";
|
||||
for (const l of rows) {
|
||||
const total = l.total_eur || 0;
|
||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${fmtDate(l.created_at)}</td>
|
||||
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
|
||||
<td>${esc(l.vehicle_label || "—")}</td>
|
||||
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</td>
|
||||
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button class="btn small ghost" data-open="${l.id}">${t("adminDetails")}</button>
|
||||
@@ -387,34 +399,203 @@ function renderLeads() {
|
||||
leadsTableBody.querySelectorAll("[data-reopen]").forEach(b => b.addEventListener("click", () => reopenLead(b.dataset.reopen)));
|
||||
}
|
||||
|
||||
function openLead(id) {
|
||||
// ----- 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 getAttachmentUrl(attachment) {
|
||||
try {
|
||||
const { data: pub } = supabase.storage
|
||||
.from(attachment.bucket)
|
||||
.getPublicUrl(attachment.file_path);
|
||||
return pub?.publicUrl || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn small ghost" href="#" data-download="${d.file_path}" 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}`;
|
||||
leadDialogBody.innerHTML = `
|
||||
<dl class="kv">
|
||||
<dt>Eingang</dt><dd>${fmtDate(l.created_at)}</dd>
|
||||
<dt>E-Mail</dt><dd><a href="mailto:${attr(l.email)}">${esc(l.email)}</a></dd>
|
||||
<dt>Telefon</dt><dd>${esc(l.phone || "—")}</dd>
|
||||
<dt>Fahrzeug</dt><dd>${esc(l.vehicle_label || "—")}</dd>
|
||||
<dt>Zeitraum</dt><dd>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</dd>
|
||||
<dt>Nachricht</dt><dd style="white-space:pre-wrap;">${esc(l.message || "—")}</dd>
|
||||
<dt>Status</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
|
||||
<dt>Notiz</dt><dd><textarea id="leadNote" rows="3" style="width:100%;">${esc(l.admin_notes || "")}</textarea></dd>
|
||||
</dl>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.8rem;">
|
||||
${l.is_active ? `
|
||||
<button class="btn danger" id="dlgDisq">${t("adminReject")}</button>
|
||||
<button class="btn" id="dlgQual">${t("adminQualify")}</button>
|
||||
` : `<button class="btn ghost" id="dlgReopen">${t("adminReopen")}</button>`}
|
||||
</div>`;
|
||||
|
||||
// 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();
|
||||
const note = () => document.querySelector("#leadNote").value;
|
||||
document.querySelector("#dlgQual")?.addEventListener("click", () => qualifyLead(l.id, note()));
|
||||
document.querySelector("#dlgDisq")?.addEventListener("click", () => disqualifyLead(l.id, note()));
|
||||
document.querySelector("#dlgReopen")?.addEventListener("click", () => reopenLead(l.id));
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// Download handlers
|
||||
leadDialogBody.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");
|
||||
});
|
||||
});
|
||||
|
||||
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>Quelle</dt><dd>${esc(l.source || "website")}</dd>
|
||||
<dt>${t("adminStatus")}</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
|
||||
<dt>${t("adminNote")}</dt><dd><textarea id="leadNote" rows="3" style="width:100%;resize:vertical;">${esc(l.admin_notes || "")}</textarea></dd>
|
||||
</dl>`;
|
||||
// Re-bind note save
|
||||
const noteArea = document.querySelector("#leadNote");
|
||||
const saveNoteBtn = document.createElement("button");
|
||||
saveNoteBtn.className = "btn small";
|
||||
saveNoteBtn.textContent = t("adminSave");
|
||||
saveNoteBtn.addEventListener("click", async () => {
|
||||
const { error } = await supabase.from("leads").update({ admin_notes: noteArea.value }).eq("id", l.id);
|
||||
if (error) { alert(error.message); }
|
||||
else { saveNoteBtn.textContent = "✓"; setTimeout(() => { saveNoteBtn.textContent = t("adminSave"); }, 1500); }
|
||||
});
|
||||
leadDialogBody.appendChild(saveNoteBtn);
|
||||
} else if (tab === "pricing") {
|
||||
const daily = l.daily_subtotal || 0;
|
||||
const weekend = l.weekend_subtotal || 0;
|
||||
const sub = l.subtotal_eur || 0;
|
||||
const vat = l.vat_eur || 0;
|
||||
const total = l.total_eur || 0;
|
||||
const deposit = l.deposit_eur || 0;
|
||||
const lang = getLang();
|
||||
leadDialogBody.innerHTML = `
|
||||
<div class="pricing-card">
|
||||
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${l.weekday_count || 0} × € ${l.daily_subtotal && l.weekday_count ? Math.round(daily / l.weekday_count) : "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${l.weekend_day_count || 0} × € ${l.weekend_subtotal && l.weekend_day_count ? Math.round(weekend / l.weekend_day_count) : "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${((l.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150))} km</span></div>
|
||||
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
|
||||
</div>`;
|
||||
} else if (tab === "documents") {
|
||||
const docs = await loadLeadAttachments(l.id);
|
||||
leadDialogBody.innerHTML = renderDocList(docs);
|
||||
// Re-bind downloads
|
||||
leadDialogBody.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");
|
||||
});
|
||||
});
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
leadDialogClose.addEventListener("click", () => leadDialog.close());
|
||||
|
||||
async function qualifyLead(id, notes = "") {
|
||||
const { error } = await supabase.rpc("qualify_lead", { p_lead_id: id, p_notes: notes });
|
||||
@@ -444,6 +625,192 @@ async function reopenLead(id) {
|
||||
// =========================================================================
|
||||
// CUSTOMERS
|
||||
// =========================================================================
|
||||
// ----- 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;
|
||||
}
|
||||
}
|
||||
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 || [];
|
||||
}
|
||||
|
||||
// ----- ORDER HISTORY -----
|
||||
async function loadOrderHistory(email) {
|
||||
const { data, error } = await supabase
|
||||
.from("leads")
|
||||
.select("*")
|
||||
.eq("email", email)
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) { console.error(error); return []; }
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ----- CUSTOMER DIALOG (tabbed) -----
|
||||
const customerTabOrder = ["info", "documents", "orderHistory"];
|
||||
const customerTabLabels = {
|
||||
info: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
|
||||
documents: () => getLang() === "de" ? t("adminTabDocuments") : t("adminTabDocumentsEn"),
|
||||
orderHistory: () => getLang() === "de" ? t("adminTabOrderHistory") : t("adminTabOrderHistory"),
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
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" ? 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>
|
||||
</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); }
|
||||
});
|
||||
customerDialogBody.appendChild(saveBtn);
|
||||
} else if (tab === "documents") {
|
||||
// Show customer_attachments + inherited lead_attachments
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
} else if (tab === "orderHistory") {
|
||||
const orders = await loadOrderHistory(c.email);
|
||||
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 += `</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>`;
|
||||
}
|
||||
customerDialogBody.innerHTML = html;
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -458,20 +825,25 @@ 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 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 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-toggle]").forEach(b => b.addEventListener("click", async () => {
|
||||
const id = b.dataset.toggle;
|
||||
const next = b.dataset.status === "active" ? "inactive" : "active";
|
||||
|
||||
+34
-9
@@ -410,16 +410,41 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => {
|
||||
bookingFeedback.textContent = "...";
|
||||
|
||||
const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
|
||||
const { from, to } = getBpfDates();
|
||||
const vFrom = parseYmdLocal(from);
|
||||
const vTo = parseYmdLocal(to);
|
||||
let weekdayCost = 0, weekendCost = 0, subtotal = 0, vat = 0, total = 0, deposit = 0;
|
||||
let totalDays = 0, weekdays = 0, weekendDays = 0;
|
||||
if (vehicle && vFrom && vTo && vTo > vFrom) {
|
||||
totalDays = Math.ceil((vTo - vFrom) / (1000 * 60 * 60 * 24));
|
||||
weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to);
|
||||
weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays);
|
||||
weekdayCost = weekdays * vehicle.daily_price_eur;
|
||||
weekendCost = weekendDays * (vehicle.weekend_price_eur || vehicle.daily_price_eur);
|
||||
subtotal = weekdayCost + weekendCost;
|
||||
vat = Math.round(subtotal * 0.20);
|
||||
total = subtotal + vat;
|
||||
deposit = vehicle.kaution_eur || 5000;
|
||||
}
|
||||
const payload = {
|
||||
p_name: bpfName.value,
|
||||
p_email: bpfEmail.value,
|
||||
p_phone: bpfPhone.value || "",
|
||||
p_vehicle_id: bpfCar.value || null,
|
||||
p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
|
||||
p_date_from: bpfFrom.value || null,
|
||||
p_date_to: bpfTo.value || null,
|
||||
p_message: bpfMessage.value || "",
|
||||
p_source: "website",
|
||||
p_name: bpfName.value,
|
||||
p_email: bpfEmail.value,
|
||||
p_phone: bpfPhone.value || "",
|
||||
p_vehicle_id: bpfCar.value || null,
|
||||
p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
|
||||
p_date_from: bpfFrom.value || null,
|
||||
p_date_to: bpfTo.value || null,
|
||||
p_message: bpfMessage.value || "",
|
||||
p_source: "website",
|
||||
p_daily_subtotal: weekdayCost,
|
||||
p_weekend_subtotal: weekendCost,
|
||||
p_subtotal_eur: subtotal,
|
||||
p_vat_eur: vat,
|
||||
p_total_eur: total,
|
||||
p_deposit_eur: deposit,
|
||||
p_total_days: totalDays,
|
||||
p_weekday_count: weekdays,
|
||||
p_weekend_day_count: weekendDays,
|
||||
};
|
||||
|
||||
// Create lead via RPC (returns inserted id without anon SELECT privileges)
|
||||
|
||||
@@ -170,6 +170,50 @@ export const translations = {
|
||||
adminPeriod: "Zeitraum",
|
||||
adminKaution: "Kaution (€)",
|
||||
adminMaxKmWeekend: "Max. km/Wochenendtag",
|
||||
adminTotalPrice: "Gesamtbetrag",
|
||||
adminLifetimeValueCol: "Gesamtwert",
|
||||
adminTabGeneral: "Allgemein",
|
||||
adminTabGeneralEn: "General",
|
||||
adminTabPricing: "Preise",
|
||||
adminTabPricingEn: "Pricing",
|
||||
adminTabDocuments: "Dokumente",
|
||||
adminTabDocumentsEn: "Documents",
|
||||
adminTabNotes: "Notiz",
|
||||
adminTabNotesEn: "Notes",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminLifetimeValue: "Gesamtwert aller Buchungen",
|
||||
adminLifetimeValueEn: "Lifetime value",
|
||||
adminDownload: "Download",
|
||||
adminNoDocuments: "Keine Dokumente hochgeladen",
|
||||
adminNoDocumentsEn: "No documents uploaded",
|
||||
adminIdDoc: "Ausweis / Führerschein",
|
||||
adminIdDocEn: "ID / Driving license",
|
||||
adminIncomeDoc: "Lohnzettel",
|
||||
adminIncomeDocEn: "Pay slip",
|
||||
adminOtherDoc: "Sonstiges",
|
||||
adminOtherDocEn: "Other",
|
||||
adminWeekdays: "Wochentage",
|
||||
adminWeekdaysEn: "Weekdays",
|
||||
adminWeekendRateLabel: "Wochenendmiete",
|
||||
adminWeekendRateLabelEn: "Weekend rate",
|
||||
adminSubtotalLabel: "Zwischensumme",
|
||||
adminSubtotalLabelEn: "Subtotal",
|
||||
adminVatLabel: "MwSt. (20%)",
|
||||
adminVatLabelEn: "VAT (20%)",
|
||||
adminTotalLabel: "Gesamtbetrag",
|
||||
adminTotalLabelEn: "Total",
|
||||
adminDepositLabel: "Kaution",
|
||||
adminDepositLabelEn: "Deposit",
|
||||
adminIncludedKmLabel: "Inkl. km",
|
||||
adminIncludedKmLabelEn: "Included km",
|
||||
adminTotalDaysLabel: "Tage gesamt",
|
||||
adminTotalDaysLabelEn: "Total days",
|
||||
adminFirstContacted: "Erster Kontakt",
|
||||
adminFirstContactedEn: "First contacted",
|
||||
adminNote: "Notiz",
|
||||
adminNoteEn: "Note",
|
||||
adminSave: "Speichern",
|
||||
adminSaveEn: "Save",
|
||||
},
|
||||
en: {
|
||||
navCars: "Fleet",
|
||||
@@ -341,6 +385,50 @@ export const translations = {
|
||||
adminPeriod: "Period",
|
||||
adminKaution: "Deposit (€)",
|
||||
adminMaxKmWeekend: "Max. km/weekend day",
|
||||
adminTotalPrice: "Total",
|
||||
adminLifetimeValueCol: "Lifetime",
|
||||
adminTabGeneral: "General",
|
||||
adminTabGeneralEn: "Allgemein",
|
||||
adminTabPricing: "Pricing",
|
||||
adminTabPricingEn: "Preise",
|
||||
adminTabDocuments: "Documents",
|
||||
adminTabDocumentsEn: "Dokumente",
|
||||
adminTabNotes: "Notes",
|
||||
adminTabNotesEn: "Notiz",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminLifetimeValue: "Lifetime value",
|
||||
adminLifetimeValueEn: "Gesamtwert aller Buchungen",
|
||||
adminDownload: "Download",
|
||||
adminNoDocuments: "No documents uploaded",
|
||||
adminNoDocumentsEn: "Keine Dokumente hochgeladen",
|
||||
adminIdDoc: "ID / Driving license",
|
||||
adminIdDocEn: "Ausweis / Führerschein",
|
||||
adminIncomeDoc: "Pay slip",
|
||||
adminIncomeDocEn: "Lohnzettel",
|
||||
adminOtherDoc: "Other",
|
||||
adminOtherDocEn: "Sonstiges",
|
||||
adminWeekdays: "Weekdays",
|
||||
adminWeekdaysEn: "Wochentage",
|
||||
adminWeekendRateLabel: "Weekend rate",
|
||||
adminWeekendRateLabelEn: "Wochenendmiete",
|
||||
adminSubtotalLabel: "Subtotal",
|
||||
adminSubtotalLabelEn: "Zwischensumme",
|
||||
adminVatLabel: "VAT (20%)",
|
||||
adminVatLabelEn: "MwSt. (20%)",
|
||||
adminTotalLabel: "Total",
|
||||
adminTotalLabelEn: "Gesamtbetrag",
|
||||
adminDepositLabel: "Deposit",
|
||||
adminDepositLabelEn: "Kaution",
|
||||
adminIncludedKmLabel: "Included km",
|
||||
adminIncludedKmLabelEn: "Inkl. km",
|
||||
adminTotalDaysLabel: "Total days",
|
||||
adminTotalDaysLabelEn: "Tage gesamt",
|
||||
adminFirstContacted: "First contacted",
|
||||
adminFirstContactedEn: "Erster Kontakt",
|
||||
adminNote: "Note",
|
||||
adminNoteEn: "Notiz",
|
||||
adminSave: "Save",
|
||||
adminSaveEn: "Speichern",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+109
-6
@@ -1034,18 +1034,21 @@ input:checked + .toggle-slider:before {
|
||||
.btn.danger:hover { background: #8f3535; }
|
||||
|
||||
/* Dialog */
|
||||
dialog#leadDialog {
|
||||
dialog#leadDialog,
|
||||
dialog#customerDialog {
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--bg-card); color: var(--text);
|
||||
padding: 0; max-width: 580px; width: 92%;
|
||||
padding: 0; max-width: 640px; width: 94%;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
dialog#leadDialog[open] {
|
||||
dialog#leadDialog[open],
|
||||
dialog#customerDialog[open] {
|
||||
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
dialog#leadDialog::backdrop {
|
||||
background: rgba(0,0,0,0.7);
|
||||
dialog#leadDialog::backdrop,
|
||||
dialog#customerDialog::backdrop {
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
@@ -1071,10 +1074,110 @@ dialog#leadDialog::backdrop {
|
||||
color: var(--text);
|
||||
}
|
||||
.dialog-body { padding: 1.6rem; }
|
||||
dl.kv { display: grid; grid-template-columns: 120px 1fr; gap: 0.6rem 1rem; margin: 0; font-size: 0.9rem; }
|
||||
dl.kv { display: grid; grid-template-columns: 140px 1fr; gap: 0.6rem 1rem; margin: 0; font-size: 0.9rem; }
|
||||
dl.kv dt { color: var(--muted); font-weight: 500; }
|
||||
dl.kv dd { margin: 0; color: var(--text); }
|
||||
|
||||
/* Dialog tabs */
|
||||
.dialog-tabs {
|
||||
display: flex; gap: 0.2rem;
|
||||
padding: 0 1.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.01);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.dialog-tabs button {
|
||||
background: transparent; border: none;
|
||||
color: var(--muted); padding: 0.7rem 1rem;
|
||||
font-size: 0.85rem; font-family: "Inter", sans-serif;
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dialog-tabs button:hover {
|
||||
color: var(--text); background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.dialog-tabs button.active {
|
||||
color: var(--accent-strong); border-bottom-color: var(--accent);
|
||||
background: rgba(196, 138, 66, 0.05);
|
||||
}
|
||||
|
||||
/* Dialog footer */
|
||||
.dialog-footer {
|
||||
padding: 1rem 1.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.01);
|
||||
}
|
||||
|
||||
/* Pricing card */
|
||||
.pricing-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 1.4rem;
|
||||
}
|
||||
.price-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.5rem 0; font-size: 0.92rem;
|
||||
}
|
||||
.price-row.total {
|
||||
font-weight: 700; font-size: 1.15rem; color: var(--accent-strong);
|
||||
border-top: 2px solid var(--accent);
|
||||
padding-top: 0.8rem; margin-top: 0.4rem;
|
||||
}
|
||||
.price-row.divider {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.6rem; margin-top: 0.2rem;
|
||||
}
|
||||
.price-row.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
/* Document items */
|
||||
.doc-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.6rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.doc-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.doc-info {
|
||||
display: flex; align-items: center; gap: 0.8rem; min-width: 0;
|
||||
}
|
||||
.doc-icon {
|
||||
font-size: 1.4rem; flex-shrink: 0;
|
||||
}
|
||||
.doc-info strong {
|
||||
display: block; font-size: 0.9rem; word-break: break-all;
|
||||
}
|
||||
.doc-info .muted {
|
||||
font-size: 0.8rem; margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* ---------------- Responsive ---------------- */
|
||||
@media (max-width: 900px) {
|
||||
.filters, .booking-form, .admin-grid, .why-grid { grid-template-columns: 1fr; }
|
||||
.footer-grid { grid-template-columns: 1fr 1fr; }
|
||||
.section-head { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.main-nav { display: none; position: absolute; right: 1rem; top: 100%; flex-direction: column; background: var(--bg-elev); border: 1px solid var(--line); padding: 1rem; border-radius: var(--radius); }
|
||||
.main-nav.open { display: flex; }
|
||||
.menu-toggle { display: inline-flex; }
|
||||
.footer-grid { grid-template-columns: 1fr; }
|
||||
.admin-form .row2, .admin-form .row3 { grid-template-columns: 1fr; }
|
||||
dl.kv { grid-template-columns: 100px 1fr; }
|
||||
.dialog-tabs { padding: 0 0.8rem; }
|
||||
.dialog-tabs button { padding: 0.6rem 0.7rem; font-size: 0.8rem; }
|
||||
.dialog-body { padding: 1rem; }
|
||||
.dialog-head { padding: 1rem 1.2rem; }
|
||||
.dialog-footer { padding: 0.8rem 1.2rem; }
|
||||
}
|
||||
|
||||
/* ---------------- Responsive ---------------- */
|
||||
@media (max-width: 900px) {
|
||||
.filters, .booking-form, .admin-grid, .why-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# Admin Portal Rework — Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Rework the Admin Portal to show:
|
||||
1. **Full pricing breakdown** on every Lead (captured at booking time)
|
||||
2. **Documents tab** inside Lead detail (view/download uploaded ID & income proof)
|
||||
3. **Enhanced Customer detail** with Documents tab + Order History (all leads merged via email)
|
||||
4. **UI polish** — integrate all new features smoothly into the existing dark theme
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
leads {
|
||||
uuid id PK
|
||||
text name
|
||||
text email
|
||||
text phone
|
||||
uuid vehicle_id FK
|
||||
text vehicle_label
|
||||
date date_from
|
||||
date date_to
|
||||
text message
|
||||
text status
|
||||
boolean is_active
|
||||
text admin_notes
|
||||
text source
|
||||
timestamptz created_at
|
||||
timestamptz updated_at
|
||||
timestamptz qualified_at
|
||||
uuid qualified_by FK
|
||||
integer daily_subtotal
|
||||
integer weekend_subtotal
|
||||
integer subtotal_eur
|
||||
integer vat_eur
|
||||
integer total_eur
|
||||
integer deposit_eur
|
||||
integer total_days
|
||||
integer weekday_count
|
||||
integer weekend_day_count
|
||||
}
|
||||
|
||||
lead_attachments {
|
||||
uuid id PK
|
||||
uuid lead_id FK
|
||||
text bucket
|
||||
text file_path
|
||||
text file_name
|
||||
text mime_type
|
||||
text kind
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
customers {
|
||||
uuid id PK
|
||||
uuid lead_id FK
|
||||
text name
|
||||
text email
|
||||
text phone
|
||||
timestamptz first_contacted_at
|
||||
text notes
|
||||
text status
|
||||
timestamptz created_at
|
||||
timestamptz updated_at
|
||||
uuid created_by FK
|
||||
}
|
||||
|
||||
customer_attachments {
|
||||
uuid id PK
|
||||
uuid customer_id FK
|
||||
uuid lead_id FK
|
||||
text bucket
|
||||
text file_path
|
||||
text file_name
|
||||
text mime_type
|
||||
text kind
|
||||
timestamptz created_at
|
||||
}
|
||||
|
||||
vehicles {
|
||||
uuid id PK
|
||||
text brand
|
||||
text model
|
||||
integer daily_price_eur
|
||||
integer weekend_price_eur
|
||||
integer kaution_eur
|
||||
integer max_daily_km
|
||||
integer max_km_weekend
|
||||
}
|
||||
|
||||
leads }o--o{ lead_attachments : "has"
|
||||
leads }o--o{ customers : "becomes"
|
||||
customers }o--o{ customer_attachments : "has"
|
||||
customers }o--o{ customers : "order history (same email)"
|
||||
leads }o--o{ vehicles : "references"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### New Migration: `06-admin-pricing-documents.sql`
|
||||
|
||||
#### 1. Add pricing columns to `leads` table
|
||||
|
||||
```sql
|
||||
alter table public.leads add column if not exists daily_subtotal integer not null default 0;
|
||||
alter table public.leads add column if not exists weekend_subtotal integer not null default 0;
|
||||
alter table public.leads add column if not exists subtotal_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists vat_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists total_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists deposit_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists total_days integer not null default 0;
|
||||
alter table public.leads add column if not exists weekday_count integer not null default 0;
|
||||
alter table public.leads add column if not exists weekend_day_count integer not null default 0;
|
||||
```
|
||||
|
||||
These columns capture a **snapshot** of the pricing at booking time. They are never modified after creation.
|
||||
|
||||
#### 2. Update `create_lead` RPC (in `05-create-lead-rpc.sql`)
|
||||
|
||||
The RPC needs to accept optional pricing parameters and store them:
|
||||
|
||||
```sql
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
-- New pricing snapshot params
|
||||
p_daily_subtotal integer default 0,
|
||||
p_weekend_subtotal integer default 0,
|
||||
p_subtotal_eur integer default 0,
|
||||
p_vat_eur integer default 0,
|
||||
p_total_eur integer default 0,
|
||||
p_deposit_eur integer default 0,
|
||||
p_total_days integer default 0,
|
||||
p_weekday_count integer default 0,
|
||||
p_weekend_day_count integer default 0
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. Update frontend `app.js` — pass pricing to RPC
|
||||
|
||||
In the existing `bpfSubmit` handler ([`app.js:405`](frontend/app.js:405)), before calling `create_lead`, compute the pricing (already done in `updateSidebar`) and pass it as additional payload:
|
||||
|
||||
```js
|
||||
const payload = {
|
||||
p_name: bpfName.value,
|
||||
p_email: bpfEmail.value,
|
||||
// ... existing fields ...
|
||||
// NEW: pricing snapshot
|
||||
p_daily_subtotal: weekdayCost,
|
||||
p_weekend_subtotal: weekendCost,
|
||||
p_subtotal_eur: subtotal,
|
||||
p_vat_eur: vat,
|
||||
p_total_eur: total,
|
||||
p_deposit_eur: deposit,
|
||||
p_total_days: totalDays,
|
||||
p_weekday_count: weekdays,
|
||||
p_weekend_day_count: weekendDays,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Changes
|
||||
|
||||
### A. Lead Table — new columns
|
||||
|
||||
The leads table ([`admin.html:90`](frontend/admin.html:90)) gains two new columns:
|
||||
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| Eingang | — |
|
||||
| Name / E-Mail | — |
|
||||
| Fahrzeug | — |
|
||||
| Zeitraum | — |
|
||||
| **Preis Gesamt (€)** | ← NEW |
|
||||
| Status | — |
|
||||
| (actions) | — |
|
||||
|
||||
Reordered columns: `Eingang → Name/E-Mail → Fahrzeug → Zeitraum → Preis Gesamt → Status → Aktionen`
|
||||
|
||||
### B. Lead Detail Dialog — full redesign
|
||||
|
||||
Replace the current simple `dl.kv` key-value list with a **tabbed dialog**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ John Doe · new [×] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [Allgemein] [Preise] [Dokumente] [Notiz] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Tab content... │
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [Ablehnen] [Qualifizieren] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tab: "Allgemein" (General)
|
||||
- Name, E-Mail, Telefon
|
||||
- Fahrzeug (brand + model)
|
||||
- Zeitraum (date_from → date_to, total_days)
|
||||
- Nachricht (message, multiline)
|
||||
- Quelle (source)
|
||||
- Eingang (created_at)
|
||||
|
||||
#### Tab: "Preise" (Pricing)
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Tagesmiete (3 × € 850) € 2.550│
|
||||
│ Wochenendmiete (0 × € 0) € 0│
|
||||
│ ─────────────────────────────────── │
|
||||
│ Zwischensumme € 2.550│
|
||||
│ MwSt. (20%) € 510│
|
||||
│ ─────────────────────────────────── │
|
||||
│ Gesamtbetrag € 3.060│
|
||||
│ │
|
||||
│ Kaution € 5.000│
|
||||
│ Inkl. km 450 km │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tab: "Dokumente" (Documents)
|
||||
- List all `lead_attachments` for this lead
|
||||
- Each row: document kind icon, file name, MIME type, upload date, **Download** button
|
||||
- Download link uses Supabase `getPublicUrl` for `customer-documents` bucket (private bucket, requires authenticated JWT)
|
||||
- If no documents: show "Keine Dokumente hochgeladen"
|
||||
|
||||
#### Tab: "Notiz" (Notes)
|
||||
- `admin_notes` textarea (editable)
|
||||
- Save button to update via `supabase.from("leads").update({ admin_notes })`
|
||||
|
||||
### C. Customer Detail — new dialog
|
||||
|
||||
Currently customers have **no detail dialog**. Add one:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ John Doe · active [×] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [Info] [Dokumente] [Order History] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Tab content... │
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ [Inaktiv setzen] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Tab: "Info"
|
||||
- Name, E-Mail, Telefon
|
||||
- Erster Kontakt (first_contacted_at)
|
||||
- Status (active/inactive toggle)
|
||||
- Notizen (notes, editable)
|
||||
|
||||
#### Tab: "Dokumente"
|
||||
- List all `customer_attachments` for this customer
|
||||
- Each row: document kind icon, file name, MIME type, upload date, **Download** button
|
||||
- Also show any documents inherited from the original lead (via `lead_id` join)
|
||||
|
||||
#### Tab: "Order History"
|
||||
- Query: `select * from leads where lower(email) = lower(customer.email) order by created_at desc`
|
||||
- Table: Eingang → Fahrzeug → Zeitraum → Gesamtbetrag → Status
|
||||
- Footer: **Gesamtwert aller Buchungen** (sum of total_eur)
|
||||
|
||||
### D. Customer Table — new column
|
||||
|
||||
Add `Gesamtwert` (total lifetime value) column:
|
||||
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| Erster Kontakt | — |
|
||||
| Name / E-Mail | — |
|
||||
| Telefon | — |
|
||||
| Quelle (Lead) | — |
|
||||
| **Gesamtwert** | ← NEW |
|
||||
| Status | — |
|
||||
| (actions) | — |
|
||||
|
||||
Compute by summing `total_eur` from all associated leads (via email match).
|
||||
|
||||
---
|
||||
|
||||
## i18n Additions
|
||||
|
||||
Add to [`frontend/i18n.js`](frontend/i18n.js) translations object (both `de` and `en`):
|
||||
|
||||
```js
|
||||
// Lead table
|
||||
adminTotalPrice: "Gesamtbetrag",
|
||||
adminTotalPriceEn: "Total",
|
||||
|
||||
// Lead dialog tabs
|
||||
adminTabGeneral: "Allgemein",
|
||||
adminTabGeneralEn: "General",
|
||||
adminTabPricing: "Preise",
|
||||
adminTabPricingEn: "Pricing",
|
||||
adminTabDocuments: "Dokumente",
|
||||
adminTabDocumentsEn: "Documents",
|
||||
adminTabNotes: "Notiz",
|
||||
adminTabNotesEn: "Notes",
|
||||
|
||||
// Pricing tab
|
||||
adminWeekdays: "Tagesmiete",
|
||||
adminWeekdaysEn: "Weekday rate",
|
||||
adminWeekendRateLabel: "Wochenendmiete",
|
||||
adminWeekendRateLabelEn: "Weekend rate",
|
||||
adminSubtotalLabel: "Zwischensumme",
|
||||
adminSubtotalLabelEn: "Subtotal",
|
||||
adminVatLabel: "MwSt. (20%)",
|
||||
adminVatLabelEn: "VAT (20%)",
|
||||
adminTotalLabel: "Gesamtbetrag",
|
||||
adminTotalLabelEn: "Total",
|
||||
adminDepositLabel: "Kaution",
|
||||
adminDepositLabelEn: "Deposit",
|
||||
adminIncludedKmLabel: "Inkl. km",
|
||||
adminIncludedKmLabelEn: "Included km",
|
||||
adminTotalDaysLabel: "Tage gesamt",
|
||||
adminTotalDaysLabelEn: "Total days",
|
||||
|
||||
// Documents tab
|
||||
adminDownload: "Download",
|
||||
adminDownloadEn: "Download",
|
||||
adminNoDocuments: "Keine Dokumente hochgeladen",
|
||||
adminNoDocumentsEn: "No documents uploaded",
|
||||
adminIdDoc: "Ausweis / Führerschein",
|
||||
adminIdDocEn: "ID / Driving license",
|
||||
adminIncomeDoc: "Lohnzettel",
|
||||
adminIncomeDocEn: "Pay slip",
|
||||
adminOtherDoc: "Sonstiges",
|
||||
adminOtherDocEn: "Other",
|
||||
|
||||
// Customer dialog
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminTabOrderHistoryEn: "Order History",
|
||||
adminLifetimeValue: "Gesamtwert aller Buchungen",
|
||||
adminLifetimeValueEn: "Lifetime value",
|
||||
adminFirstContacted: "Erster Kontakt",
|
||||
adminFirstContactedEn: "First contacted",
|
||||
|
||||
// Customer table
|
||||
adminLifetimeValueCol: "Gesamtwert",
|
||||
adminLifetimeValueColEn: "Lifetime",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `supabase/migrations/06-admin-pricing-documents.sql` | **NEW** — pricing columns, updated `create_lead` RPC |
|
||||
| `supabase/migrations/post-boot.sql` | Append `06-admin-pricing-documents.sql` to entrypoint command |
|
||||
| `docker-compose.yml` | Mount new migration file in `post-init` service |
|
||||
| `frontend/app.js` | Pass pricing snapshot in `create_lead` RPC call |
|
||||
| `frontend/admin.html` | Add customer detail dialog, update lead dialog structure with tabs |
|
||||
| `frontend/admin.js` | Rewrite `openLead()` with tabbed dialog, add `openCustomer()` dialog, add document rendering, add order history query, update `renderCustomers()` with lifetime value |
|
||||
| `frontend/styles.css` | Add tab styles for lead/customer dialogs, document list styles, pricing card styles |
|
||||
| `frontend/i18n.js` | Add all new translation keys |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Database migration** (`06-admin-pricing-documents.sql`) — add columns, update RPC
|
||||
2. **docker-compose.yml** — mount new migration
|
||||
3. **frontend/app.js** — pass pricing snapshot at booking time
|
||||
4. **frontend/i18n.js** — add translation keys
|
||||
5. **frontend/admin.html** — add tabbed dialog HTML structures
|
||||
6. **frontend/admin.js** — rewrite `openLead()`, add `openCustomer()`, document rendering, order history
|
||||
7. **frontend/styles.css** — add tab, document list, pricing card styles
|
||||
|
||||
---
|
||||
|
||||
## Diagram: Lead Detail Dialog Flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LeadTableClick
|
||||
LeadTableClick --> LeadDialogOpen
|
||||
LeadDialogOpen --> TabGeneral
|
||||
LeadDialogOpen --> TabPricing
|
||||
LeadDialogOpen --> TabDocuments
|
||||
LeadDialogOpen --> TabNotes
|
||||
TabGeneral --> LeadDialogClose
|
||||
TabPricing --> LeadDialogClose
|
||||
TabDocuments --> LeadDialogClose
|
||||
TabNotes --> TabNotesSave
|
||||
TabNotesSave --> LeadDialogClose
|
||||
TabNotesSave --> TabNotes
|
||||
LeadDialogClose --> [*]
|
||||
```
|
||||
|
||||
## Diagram: Customer Qualification Flow (updated)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin
|
||||
participant AdminUI
|
||||
participant RPC
|
||||
participant DB
|
||||
participant Storage
|
||||
|
||||
Admin->>AdminUI: Click "Qualifizieren"
|
||||
AdminUI->>RPC: qualify_lead(lead_id, notes)
|
||||
RPC->>DB: Mark lead qualified + inactive
|
||||
RPC->>DB: Upsert customer by email
|
||||
RPC->>DB: Transfer lead_attachments to customer_attachments
|
||||
DB-->>RPC: customer row
|
||||
RPC-->>AdminUI: customer
|
||||
AdminUI->>DB: Reload leads + customers (realtime)
|
||||
AdminUI->>AdminUI: Refresh tables
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All migrations are **idempotent** (`add column if not exists`, `create or replace function`)
|
||||
- Existing leads will have `0` for all new pricing columns — a one-time backfill RPC can be added later if needed to retroactively compute prices for historical leads
|
||||
- Documents in `customer-documents` bucket are **private** — admin must be authenticated to download (Supabase JWT handles this)
|
||||
- The `lead_attachments` and `customer_attachments` tables already exist from migration `03-booking-flow.sql` — no schema changes needed there
|
||||
@@ -0,0 +1,106 @@
|
||||
-- =============================================================================
|
||||
-- MC Cars - Admin portal rework: pricing snapshot columns on leads,
|
||||
-- updated create_lead RPC to accept pricing params.
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. Add pricing snapshot columns to leads
|
||||
-- -----------------------------------------------------------------------------
|
||||
alter table public.leads add column if not exists daily_subtotal integer not null default 0;
|
||||
alter table public.leads add column if not exists weekend_subtotal integer not null default 0;
|
||||
alter table public.leads add column if not exists subtotal_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists vat_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists total_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists deposit_eur integer not null default 0;
|
||||
alter table public.leads add column if not exists total_days integer not null default 0;
|
||||
alter table public.leads add column if not exists weekday_count integer not null default 0;
|
||||
alter table public.leads add column if not exists weekend_day_count integer not null default 0;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. Update create_lead RPC to accept and store pricing snapshot
|
||||
-- -----------------------------------------------------------------------------
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_daily_subtotal integer default 0,
|
||||
p_weekend_subtotal integer default 0,
|
||||
p_subtotal_eur integer default 0,
|
||||
p_vat_eur integer default 0,
|
||||
p_total_eur integer default 0,
|
||||
p_deposit_eur integer default 0,
|
||||
p_total_days integer default 0,
|
||||
p_weekday_count integer default 0,
|
||||
p_weekend_day_count integer default 0
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
insert into public.leads (
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
vehicle_id,
|
||||
vehicle_label,
|
||||
date_from,
|
||||
date_to,
|
||||
message,
|
||||
source,
|
||||
daily_subtotal,
|
||||
weekend_subtotal,
|
||||
subtotal_eur,
|
||||
vat_eur,
|
||||
total_eur,
|
||||
deposit_eur,
|
||||
total_days,
|
||||
weekday_count,
|
||||
weekend_day_count
|
||||
)
|
||||
values (
|
||||
p_name,
|
||||
p_email,
|
||||
coalesce(p_phone, ''),
|
||||
p_vehicle_id,
|
||||
coalesce(p_vehicle_label, ''),
|
||||
p_date_from,
|
||||
p_date_to,
|
||||
coalesce(p_message, ''),
|
||||
coalesce(p_source, 'website'),
|
||||
coalesce(p_daily_subtotal, 0),
|
||||
coalesce(p_weekend_subtotal, 0),
|
||||
coalesce(p_subtotal_eur, 0),
|
||||
coalesce(p_vat_eur, 0),
|
||||
coalesce(p_total_eur, 0),
|
||||
coalesce(p_deposit_eur, 0),
|
||||
coalesce(p_total_days, 0),
|
||||
coalesce(p_weekday_count, 0),
|
||||
coalesce(p_weekend_day_count, 0)
|
||||
)
|
||||
returning id into v_id;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer
|
||||
) from public;
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user