From 30e296f61b92c0e92a813a26c9a01a04f0c123e1 Mon Sep 17 00:00:00 2001 From: LagoESP Date: Wed, 29 Apr 2026 17:27:37 +0200 Subject: [PATCH] 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. --- docker-compose.local.yml | 3 +- docker-compose.yml | 2 + frontend/admin.html | 17 +- frontend/admin.js | 418 ++++++++++++++++- frontend/app.js | 43 +- frontend/i18n.js | 88 ++++ frontend/styles.css | 115 ++++- plans/admin-portal-rework.md | 435 ++++++++++++++++++ .../migrations/06-admin-pricing-documents.sql | 106 +++++ 9 files changed, 1187 insertions(+), 40 deletions(-) create mode 100644 plans/admin-portal-rework.md create mode 100644 supabase/migrations/06-admin-pricing-documents.sql 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 @@ - +

Lead

+
+ +
+ + + +
+

Kunde

+ +
+
+
+
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 += ` + + + + + + + + `; + for (const o of orders) { + const total = o.total_eur || 0; + html += ` + + + + + + `; + } + html += `
${lang === "de" ? "Eingang" : "Received"}Fahrzeug${lang === "de" ? "Zeitraum" : "Period"}${lang === "de" ? t("adminTotalPrice") : t("adminTotalPrice")}${lang === "de" ? "Status" : "Status"}
${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)}
`; + 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';