diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index b5d3f60..5c664f1 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -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
\ No newline at end of file
+ - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
diff --git a/docker-compose.yml b/docker-compose.yml
index 07dfd74..4da46e6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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]
diff --git a/frontend/admin.html b/frontend/admin.html
index 3cacf1d..f3568ff 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -94,6 +94,7 @@
Name / E-Mail |
Fahrzeug |
Zeitraum |
+ Gesamtbetrag |
Status |
|
@@ -116,6 +117,7 @@
Name / E-Mail |
Telefon |
Quelle (Lead) |
+ Gesamtwert |
Status |
|
@@ -213,13 +215,26 @@
-
+
+
+
+
diff --git a/frontend/admin.js b/frontend/admin.js
index f793aed..e84336c 100644
--- a/frontend/admin.js
+++ b/frontend/admin.js
@@ -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 = `
${fmtDate(l.created_at)} |
${esc(l.name)} ${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""} |
${esc(l.vehicle_label || "—")} |
${esc(l.date_from || "—")} → ${esc(l.date_to || "—")} |
+ ${totalStr} |
${esc(l.status)} |
@@ -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 ` ${lang === "de" ? t("adminNoDocuments") : t("adminNoDocumentsEn")} `;
+ }
+ let html = "";
+ for (const d of docs) {
+ html += `
+
+
+ ${docKindIcon(d.kind)}
+
+ ${esc(d.file_name)}
+ ${docKindLabel(d.kind)} · ${fmtDate(d.created_at)}
+
+
+ ⬇
+ `;
+ }
+ 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 = `
-
- - Eingang
- ${fmtDate(l.created_at)}
- - E-Mail
- ${esc(l.email)}
- - Telefon
- ${esc(l.phone || "—")}
- - Fahrzeug
- ${esc(l.vehicle_label || "—")}
- - Zeitraum
- ${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}
- - Nachricht
- ${esc(l.message || "—")}
- - Status
- ${esc(l.status)}
- - Notiz
-
-
- ${l.is_active ? `
-
-
- ` : ``}
- `;
+
+ // Build tabs
+ leadDialogTabs.innerHTML = leadTabOrder.map((tab, i) =>
+ ``
+ ).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 = `
+
+ - ${t("adminReceived")}
- ${fmtDate(l.created_at)}
+ - E-Mail
- ${esc(l.email)}
+ - ${t("adminPhone")}
- ${esc(l.phone || "—")}
+ - Fahrzeug
- ${esc(l.vehicle_label || "—")}
+ - ${t("adminPeriod")}
- ${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}${l.total_days ? " (" + l.total_days + " " + (getLang() === "de" ? t("bpfDays") : t("bpfDaysEn")) + ")" : ""}
+ - Quelle
- ${esc(l.source || "website")}
+ - ${t("adminStatus")}
- ${esc(l.status)}
+ - ${t("adminNote")}
+ `;
+ // 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 = `
+
+ ${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${l.weekday_count || 0} × € ${l.daily_subtotal && l.weekday_count ? Math.round(daily / l.weekday_count) : "—"})€ ${daily.toLocaleString("de-DE")}
+ ${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) : "—"})€ ${weekend.toLocaleString("de-DE")}
+ ${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}€ ${sub.toLocaleString("de-DE")}
+ ${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}€ ${vat.toLocaleString("de-DE")}
+ ${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}€ ${total.toLocaleString("de-DE")}
+ ${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}€ ${deposit.toLocaleString("de-DE")}
+ ${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}${((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
+ ${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}${l.total_days || 0}
+ `;
+ } 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 = `
+
+
+
+ `;
+ 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 = `
+
+
+
+ `;
+ 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 = `
+
+
+ `;
+ 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) =>
+ ``
+ ).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 = `
+
+ - ${lang === "de" ? "Name" : "Name"}
- ${esc(c.name)}
+ - E-Mail
- ${esc(c.email)}
+ - ${lang === "de" ? t("adminPhone") : "Phone"}
- ${esc(c.phone || "—")}
+ - ${lang === "de" ? t("adminFirstContacted") : t("adminFirstContactedEn")}
- ${fmtDate(c.first_contacted_at)}
+ - ${lang === "de" ? "Status" : "Status"}
- ${esc(c.status)}
+ - ${lang === "de" ? t("adminNote") : t("adminNoteEn")}
+ `;
+ 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 += `${c.lead_id ? "─ Von Lead übernommen" : ""}`;
+ html += renderDocList(leadDocs);
+ }
+ if (custDocs.length) {
+ html += `─ Direkt hochgeladen`;
+ html += renderDocList(custDocs);
+ }
+ if (!leadDocs.length && !custDocs.length) {
+ html = `${getLang() === "de" ? t("adminNoDocuments") : t("adminNoDocumentsEn")} `;
+ }
+ 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 += `
+
+ | ${lang === "de" ? "Eingang" : "Received"} |
+ Fahrzeug |
+ ${lang === "de" ? "Zeitraum" : "Period"} |
+ ${lang === "de" ? t("adminTotalPrice") : t("adminTotalPrice")} |
+ ${lang === "de" ? "Status" : "Status"} |
+
+ `;
+ for (const o of orders) {
+ const total = o.total_eur || 0;
+ html += `
+ | ${fmtDate(o.created_at)} |
+ ${esc(o.vehicle_label || "—")} |
+ ${esc(o.date_from || "—")} → ${esc(o.date_to || "—")} |
+ ${total > 0 ? "€ " + total.toLocaleString("de-DE") : "—"} |
+ ${esc(o.status)} |
+ `;
+ }
+ html += ` `;
+ const lifetime = calcCustomerLifetimeValue(c);
+ html += `
+ ${lang === "de" ? t("adminLifetimeValue") : t("adminLifetimeValueEn")}€ ${lifetime.toLocaleString("de-DE")}
+ `;
+ } else {
+ html = `Keine Buchungen gefunden. `;
+ }
+ customerDialogBody.innerHTML = html;
+ }
+
+ // Footer
+ customerDialogFooter.innerHTML = `
+
+
+ `;
+ 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 = `
| ${fmtDate(c.first_contacted_at)} |
${esc(c.name)} ${esc(c.email)} |
${esc(c.phone || "—")} |
${esc(c.lead_id?.slice(0, 8) || "—")} |
+ ${lifetimeStr} |
${esc(c.status)} |
+
| `;
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";
diff --git a/frontend/app.js b/frontend/app.js
index 82a75e6..4bfda6d 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -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)
diff --git a/frontend/i18n.js b/frontend/i18n.js
index 3775354..dc14f7c 100644
--- a/frontend/i18n.js
+++ b/frontend/i18n.js
@@ -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",
},
};
diff --git a/frontend/styles.css b/frontend/styles.css
index 4c0165b..21cb027 100644
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -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; }
diff --git a/plans/admin-portal-rework.md b/plans/admin-portal-rework.md
new file mode 100644
index 0000000..fb3bc85
--- /dev/null
+++ b/plans/admin-portal-rework.md
@@ -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
diff --git a/supabase/migrations/06-admin-pricing-documents.sql b/supabase/migrations/06-admin-pricing-documents.sql
new file mode 100644
index 0000000..ed1933e
--- /dev/null
+++ b/supabase/migrations/06-admin-pricing-documents.sql
@@ -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';