diff --git a/.gitignore b/.gitignore index 98202d3..7f5f52e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ docker-compose.override.yml frontend/config.js .playwright-mcp +node_modules/ \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 5e8121b..f07ec0d 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -25,6 +25,11 @@ services: - ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro - ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro - ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro + - ./supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro + - ./supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro + - ./supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro + - ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro + - ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro kong: volumes: diff --git a/docker-compose.yml b/docker-compose.yml index bb13dc9..1014b30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -218,6 +218,11 @@ services: - /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro entrypoint: ["sh","-c"] command: - | @@ -244,6 +249,11 @@ services: psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/11-consolidate-km-rental.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/12-email-sent-and-more.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/13-rental-type-daily-and-email-guard.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql echo "post-init done." restart: "no" networks: [mccars] diff --git a/frontend/99-config.sh b/frontend/99-config.sh index 996cea1..69056bd 100644 --- a/frontend/99-config.sh +++ b/frontend/99-config.sh @@ -3,7 +3,8 @@ set -eu cat > /usr/share/nginx/html/config.js <Name / E-Mail Fahrzeug Zeitraum + Miettyp Gesamtbetrag Status @@ -146,10 +147,12 @@ Name / E-Mail Fahrzeug Zeitraum + Miettyp Gesamtbetrag Kaution Miete Status + Email @@ -192,13 +195,13 @@
- + + -
- +
@@ -278,6 +281,7 @@

Bestellung

+
@@ -304,6 +308,6 @@ - + diff --git a/frontend/admin.js b/frontend/admin.js index 7990162..8bffa9a 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -70,6 +70,55 @@ const state = { forcedRotation: false, }; +function notify(message, duration = 3000) { + if (typeof window.showToast === "function") { + window.showToast(message, duration); + return; + } + showAdminPopup(message, duration); +} + +function showAdminPopup(message, duration = 3000) { + const host = document.querySelector("dialog[open]") || document.body; + let popup = host.querySelector("[data-admin-notify-popup]"); + if (!popup) { + popup = document.createElement("div"); + popup.setAttribute("data-admin-notify-popup", "1"); + popup.setAttribute("role", "status"); + popup.setAttribute("aria-live", "polite"); + popup.style.position = host.tagName === "DIALOG" ? "absolute" : "fixed"; + popup.style.left = "50%"; + popup.style.top = "50%"; + popup.style.transform = "translate(-50%, -50%) scale(0.96)"; + popup.style.minWidth = "320px"; + popup.style.maxWidth = "min(92vw, 560px)"; + popup.style.padding = "1rem 1.2rem"; + popup.style.borderRadius = "12px"; + popup.style.border = "1px solid var(--line)"; + popup.style.background = "var(--bg-card)"; + popup.style.color = "var(--text)"; + popup.style.boxShadow = "0 16px 40px rgba(0,0,0,0.35)"; + popup.style.textAlign = "center"; + popup.style.fontSize = "1rem"; + popup.style.fontWeight = "600"; + popup.style.opacity = "0"; + popup.style.zIndex = "3000"; + popup.style.pointerEvents = "none"; + popup.style.transition = "opacity 0.18s ease, transform 0.18s ease"; + host.appendChild(popup); + } + + popup.textContent = message; + popup.style.opacity = "1"; + popup.style.transform = "translate(-50%, -50%) scale(1)"; + + clearTimeout(popup._hideTimer); + popup._hideTimer = setTimeout(() => { + popup.style.opacity = "0"; + popup.style.transform = "translate(-50%, -50%) scale(0.96)"; + }, duration); +} + // ========================================================================= // AUTH FLOW // ========================================================================= @@ -262,9 +311,9 @@ function loadForEdit(id) { vehicleForm.seats.value = v.seats; vehicleForm.daily_price_eur.value = v.daily_price_eur; vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0; - vehicleForm.max_daily_km.value = v.max_daily_km || 150; - vehicleForm.kaution_eur.value = v.kaution_eur || 5000; - vehicleForm.max_km_weekend.value = v.max_km_weekend || ''; + vehicleForm.included_km_per_day.value = v.included_km_per_day || 150; + vehicleForm.kaution_eur.value = v.kaution_eur || 5000; + vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50; vehicleForm.sort_order.value = v.sort_order; vehicleForm.location.value = v.location; vehicleForm.description_de.value = v.description_de; @@ -283,10 +332,10 @@ resetBtn.addEventListener("click", () => { vehicleForm.sort_order.value = 100; vehicleForm.location.value = "Steiermark (TBD)"; vehicleForm.seats.value = 2; - vehicleForm.max_daily_km.value = 150; + vehicleForm.included_km_per_day.value = 150; vehicleForm.weekend_price_eur.value = 0; vehicleForm.kaution_eur.value = 5000; - vehicleForm.max_km_weekend.value = ''; + vehicleForm.price_per_km_eur.value = 1.50; state.currentPhotoPath = null; updatePreview(""); formTitle.textContent = "Neues Fahrzeug"; @@ -309,9 +358,9 @@ vehicleForm.addEventListener("submit", async (e) => { seats: +fd.get("seats") || 2, daily_price_eur: +fd.get("daily_price_eur") || 0, weekend_price_eur: +fd.get("weekend_price_eur") || 0, - max_daily_km: +fd.get("max_daily_km") || 150, - kaution_eur: +fd.get("kaution_eur") || 5000, - max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null, + included_km_per_day: +fd.get("included_km_per_day") || 150, + kaution_eur: +fd.get("kaution_eur") || 5000, + price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50, sort_order: +fd.get("sort_order") || 100, location: fd.get("location") || "Steiermark (TBD)", description_de: fd.get("description_de") || "", @@ -396,6 +445,7 @@ function renderLeads() { leadsEmpty.style.display = rows.length ? "none" : "block"; leadsTableBody.innerHTML = ""; for (const l of rows) { + const rental = rentalTypeMeta(l.rental_type); const total = l.total_eur || 0; const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—"; const tr = document.createElement("tr"); @@ -404,6 +454,7 @@ function renderLeads() { ${esc(l.name)}
${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""} ${esc(l.vehicle_label || "—")} ${esc(l.date_from || "—")} → ${esc(l.date_to || "—")} + ${esc(rental.label)} ${totalStr} ${esc(l.status)} @@ -552,6 +603,7 @@ async function renderLeadTab(tab, l) { }); leadDialogBody.appendChild(saveNoteBtn); } else if (tab === "pricing") { + const rental = rentalTypeMeta(l.rental_type); const daily = l.daily_subtotal || 0; const weekend = l.weekend_subtotal || 0; const sub = l.subtotal_eur || 0; @@ -567,8 +619,9 @@ async function renderLeadTab(tab, l) {
${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}
+
${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km
+
${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}${l.total_days || 0}
+
${lang === "de" ? t("adminRentalType") : t("Rental type")}${esc(rental.label)}
`; } else if (tab === "documents") { const docs = await loadLeadAttachments(l.id); @@ -685,6 +738,7 @@ function renderOrders() { ordersEmpty.style.display = state.salesOrders.length ? "none" : "block"; ordersTableBody.innerHTML = ""; for (const o of state.salesOrders) { + const rental = rentalTypeMeta(o.rental_type); const total = o.total_eur || 0; const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—"; const cust = state.customers.find(c => c.id === o.customer_id); @@ -694,10 +748,12 @@ function renderOrders() { ${cust ? `${esc(cust.name)}
${esc(cust.email)}` : `${esc(o.customer_id?.slice(0, 8) || "—")}`} ${esc(o.vehicle_label || "—")} ${esc(o.date_from || "—")} → ${esc(o.date_to || "—")} + ${esc(rental.label)} ${totalStr} ${o.kaution_paid ? "✓" : "—"} ${o.rental_paid ? "✓" : "—"} ${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")} + ${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'} `; ordersTableBody.appendChild(tr); } @@ -708,88 +764,259 @@ function renderOrders() { // ----- ORDER DETAIL DIALOG ----- const orderDialog = document.querySelector("#orderDialog"); const orderDialogTitle = document.querySelector("#orderDialogTitle"); +const orderDialogTabs = document.querySelector("#orderDialogTabs"); const orderDialogBody = document.querySelector("#orderDialogBody"); const orderDialogFooter = document.querySelector("#orderDialogFooter"); const orderDialogClose = document.querySelector("#orderDialogClose"); +async function sendOrderEmailDirect(orderId) { + const sendBtn = orderDialogBody.querySelector("[data-manual-email-send]"); + if (sendBtn) sendBtn.disabled = true; + + const n8nUrl = (window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "http://localhost:55590") + "/webhook/manual-email-send"; + + try { + // Use urlencoded payload to avoid browser preflight/CORS issues with JSON headers. + const payload = new URLSearchParams({ sales_order_id: orderId }); + const res = await fetch(n8nUrl, { + method: "POST", + body: payload, + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + notify(t("emailSentToast"), 5000); + } catch (err) { + console.error("Webhook error:", err); + alert(`Email senden fehlgeschlagen: ${err?.message || err}`); + } +} + +orderDialogBody.addEventListener("click", async (e) => { + const sendBtn = e.target.closest("[data-manual-email-send]"); + if (!sendBtn) return; + + e.preventDefault(); + const orderId = sendBtn.dataset.orderId; + if (!orderId) return; + + await sendOrderEmailDirect(orderId); + await loadSalesOrders(); + const fresh = state.salesOrders.find(x => x.id === orderId); + if (fresh) await renderOrderTab("general", fresh, orderId); +}); + +const orderTabOrder = ["general", "pricing"]; +const orderTabLabels = { + general: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"), + pricing: () => getLang() === "de" ? t("adminTabPricing") : t("adminTabPricingEn"), +}; + async function openOrder(id) { const o = state.salesOrders.find(x => x.id === id); if (!o) return; + const cust = state.customers.find(c => c.id === o.customer_id); + + orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`; + + // Build tabs + orderDialogTabs.innerHTML = orderTabOrder.map((tab, i) => + `` + ).join(""); + + // Render first tab + await renderOrderTab("general", o, id); + + orderDialog.showModal(); + + // Tab switching + orderDialogTabs.querySelectorAll(".order-tab").forEach(btn => { + btn.addEventListener("click", () => { + orderDialogTabs.querySelectorAll(".order-tab").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + renderOrderTab(btn.dataset.orderTab, o, id); + }); + }); + + orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true }); +} + +async function renderOrderTab(tab, o, id) { + const rental = rentalTypeMeta(o.rental_type); + const isIndividuell = rental.type === "individuell"; const lang = getLang(); const cust = state.customers.find(c => c.id === o.customer_id); const total = o.total_eur || 0; const deposit = o.deposit_eur || 0; + const emailSent = o.email_sent || 0; + const emailSentText = emailSent === 1 ? '✓' : emailSent === 2 ? '✗' : '—'; + const emailSentPillClass = emailSent === 1 ? 'active' : emailSent === 2 ? 'disqualified' : 'new'; + const isEmailLocked = emailSent === 1; - orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`; + if (tab === "general") { + // Load attachments for this order + const { data: attachments } = await supabase + .from("sales_order_attachments") + .select("*") + .eq("sales_order_id", id) + .order("created_at", { ascending: false }); + const docs = attachments || []; - // Load attachments for this order - const { data: attachments } = await supabase - .from("sales_order_attachments") - .select("*") - .eq("sales_order_id", id) - .order("created_at", { ascending: false }); - const docs = attachments || []; - - orderDialogBody.innerHTML = ` -
-
${lang === "de" ? "Kunde" : "Customer"}
${cust ? `${esc(cust.name)} (${esc(cust.email)})` : esc(o.customer_id?.slice(0, 8) || "—")}
-
${lang === "de" ? "Fahrzeug" : "Vehicle"}
${esc(o.vehicle_label || "—")}
-
${lang === "de" ? "Zeitraum" : "Period"}
${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}
-
${t("adminTotalLabel")}
€ ${total.toLocaleString("de-DE")}
-
${t("adminDepositLabel")}
€ ${deposit.toLocaleString("de-DE")}
-
-
- - - -
-

${t("adminTabDocuments")}

- ${docs.length ? renderDocList(docs) : `

${t("adminNoDocuments")}

`} -
- - -
- + orderDialogBody.innerHTML = ` +
+
${lang === "de" ? "Kunde" : "Customer"}
${cust ? `${esc(cust.name)} (${esc(cust.email)})` : esc(o.customer_id?.slice(0, 8) || "—")}
+
${lang === "de" ? "Fahrzeug" : "Vehicle"}
${esc(o.vehicle_label || "—")}
+
${lang === "de" ? "Zeitraum" : "Period"}
${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}
+
${lang === "de" ? t("adminRentalType") : t("Rental type")}
${esc(rental.label)}
+
${t("adminEmailSent")}
${emailSentText}
+
+
+ + +
-
`; +

${t("adminTabDocuments")}

+ ${docs.length ? renderDocList(docs) : `

${t("adminNoDocuments")}

`} +
+ + +
+
${emailSent !== 1 ? `` : ''}
+ +
+
`; - // Customer lookup link - orderDialogBody.querySelectorAll("[data-goto-cust]").forEach(a => { - a.addEventListener("click", (e) => { - e.preventDefault(); - orderDialog.close(); - openCustomer(a.dataset.gotoCust); + // Customer lookup link + orderDialogBody.querySelectorAll("[data-goto-cust]").forEach(a => { + a.addEventListener("click", (e) => { + e.preventDefault(); + orderDialog.close(); + openCustomer(a.dataset.gotoCust); + }); }); - }); - // Document open links - orderDialogBody.querySelectorAll("[data-open-file]").forEach(btn => { - btn.addEventListener("click", async (e) => { - e.preventDefault(); - await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents"); + // Document open links + orderDialogBody.querySelectorAll("[data-open-file]").forEach(btn => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents"); + }); }); - }); - // Toggle buttons - orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => { - btn.addEventListener("click", async () => { - await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle); - await openOrder(id); // re-render + // Toggle buttons + orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => { + btn.addEventListener("click", async () => { + await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle); + await loadSalesOrders(); + const fresh = state.salesOrders.find(x => x.id === id); + if (fresh) await renderOrderTab("general", fresh, id); + }); }); - }); - // Save notes - document.querySelector("#orderNoteSave")?.addEventListener("click", async () => { - const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value); - if (ok) { - document.querySelector("#orderNoteSave").textContent = "✓"; - setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500); + // Dirty form tracking + let noteIsDirty = false; + const orderNoteEl = document.querySelector("#orderNote"); + const originalNoteValue = o.private_notes || ""; + const saveBtn = document.querySelector("#orderNoteSave"); + if (orderNoteEl && saveBtn) { + saveBtn.classList.add("ghost"); + orderNoteEl.addEventListener("input", () => { + noteIsDirty = orderNoteEl.value !== originalNoteValue; + if (noteIsDirty) { + saveBtn.classList.remove("ghost"); + saveBtn.style.backgroundColor = "var(--accent-strong)"; + saveBtn.style.color = "#fff"; + saveBtn.textContent = t("adminSaveNotes") + " (unsaved)"; + } else { + saveBtn.classList.add("ghost"); + saveBtn.style.backgroundColor = ""; + saveBtn.style.color = ""; + saveBtn.textContent = t("adminSaveNotes"); + } + }); } - }); + + // Save notes + document.querySelector("#orderNoteSave")?.addEventListener("click", async () => { + const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value); + if (ok) { + noteIsDirty = false; + document.querySelector("#orderNoteSave").textContent = "✓"; + setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500); + } + }); + + } else if (tab === "pricing") { + let daily = o.daily_subtotal || 0; + let weekend = o.weekend_subtotal || 0; + let sub = o.subtotal_eur || 0; + let vat = o.vat_eur || 0; + const days = o.total_days || 0; + const inclVatLabel = lang === "de" ? t("adminInclVat") : t("adminInclVatEn"); + + // For individuell: derive pricing from total (which includes VAT) + if (isIndividuell && total > 0) { + sub = Math.round(total / 1.2 * 100) / 100; + vat = Math.round((total - sub) * 100) / 100; + daily = sub; // all days counted as weekdays + weekend = 0; + } + + const weekdayCount = isIndividuell ? days : (o.weekday_count || 0); + const weekendCount = isIndividuell ? 0 : (o.weekend_day_count || 0); + const perDay = daily && weekdayCount ? Math.round(daily / weekdayCount) : 0; + const perWeekend = weekend && weekendCount ? Math.round(weekend / weekendCount) : 0; + + orderDialogBody.innerHTML = ` +
+
${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} × € ${perDay || "—"})€ ${daily.toLocaleString("de-DE")}
+
${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} × € ${perWeekend || "—"})€ ${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")} + ${isIndividuell && !isEmailLocked + ? `` + : '€ ' + total.toLocaleString("de-DE") + } +
+ ${!isEmailLocked ? `
${inclVatLabel}
` : ''} +
${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")} + ${isEmailLocked || !isIndividuell + ? '€ ' + deposit.toLocaleString("de-DE") + : `` + } +
+
${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km
+
${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}${days}
+
${lang === "de" ? t("adminRentalType") : t("Rental type")}${esc(rental.label)}
+
+ ${isIndividuell && !isEmailLocked ? `
` : ''}`; + + // Single save for both total + deposit + document.querySelector("#orderPricingSave")?.addEventListener("click", async () => { + const btn = document.querySelector("#orderPricingSave"); + btn.disabled = true; + const totalInput = document.querySelector("#orderTotalInput"); + const depositInput = document.querySelector("#orderDepositInput"); + const errors = []; + if (totalInput) { + const { error } = await supabase.rpc("sales_order_set_total", { p_so_id: o.id, p_total_eur: +totalInput.value }); + if (error) errors.push(error.message); + } + if (depositInput) { + const { error } = await supabase.rpc("sales_order_set_deposit", { p_so_id: o.id, p_deposit_eur: +depositInput.value }); + if (error) errors.push(error.message); + } + if (errors.length) { alert(errors.join("\n")); btn.disabled = false; return; } + await loadSalesOrders(); + const fresh = state.salesOrders.find(x => x.id === id); + if (fresh) await renderOrderTab("pricing", fresh, id); + }); + } orderDialogFooter.innerHTML = ""; - orderDialog.showModal(); - orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true }); } async function toggleSalesOrderState(orderId, action) { @@ -932,6 +1159,7 @@ async function renderCustomerTab(tab, c) { let html = ""; if (orders.length) { for (const o of orders) { + const rental = rentalTypeMeta(o.rental_type); const total = o.total_eur || 0; html += `
@@ -941,6 +1169,7 @@ async function renderCustomerTab(tab, c) {
${lang === "de" ? "Fahrzeug" : "Vehicle"}${esc(o.vehicle_label || "—")}
${lang === "de" ? "Zeitraum" : "Period"}${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}
+
${lang === "de" ? "Miettyp" : "Rental type"}${esc(rental.label)}
@@ -974,8 +1203,32 @@ async function renderCustomerTab(tab, c) { const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`); const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || ""); if (ok) { + btn.classList.remove("ghost"); + btn.style.backgroundColor = ""; + btn.style.color = ""; btn.textContent = "✓"; - setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500); + setTimeout(() => { + btn.textContent = t("adminSaveNotes"); + }, 1500); + } + }); + }); + + customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => { + const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`); + const originalValue = noteEl.value; + noteEl.addEventListener("input", () => { + const isDirty = noteEl.value !== originalValue; + if (isDirty) { + btn.classList.remove("ghost"); + btn.style.backgroundColor = "var(--accent-strong)"; + btn.style.color = "#fff"; + btn.textContent = "Speichern (unsaved)"; + } else { + btn.classList.add("ghost"); + btn.style.backgroundColor = ""; + btn.style.color = ""; + btn.textContent = t("adminSaveNotes"); } }); }); @@ -1074,6 +1327,22 @@ function attachRealtime() { // ========================================================================= function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); } function attr(s) { return esc(s); } +function normalizeRentalType(rawType) { + const value = String(rawType ?? "").trim().toLowerCase(); + if (!value) return "weekend"; + if (value === "individual" || value === "custom") return "individuell"; + if (value === "day" || value === "daily" || value === "1 tag" || value === "1_tag" || value === "single_day") return "single_day"; + if (value === "weekend") return "weekend"; + return value; +} +function rentalTypeMeta(rawType) { + const type = normalizeRentalType(rawType); + const lang = getLang(); + if (type === "single_day") return { type, label: lang === "de" ? "1 Tag" : "1 Day" }; + if (type === "individuell") return { type, label: lang === "de" ? "individuell" : "individual" }; + if (type === "weekend") return { type, label: "weekend" }; + return { type: "weekend", label: type }; +} function fmtDate(iso) { if (!iso) return "—"; const d = new Date(iso); diff --git a/frontend/app.js b/frontend/app.js index 2733c52..9d0cd6c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,5 +1,5 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; -import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js"; +import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js?v=3"; const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? ""; const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || ""; @@ -212,11 +212,11 @@ function openDetails(id) {
${v.top_speed_kmh}${t("kmh")}
${escapeHtml(v.acceleration)}${t("accel")}
-
-
${v.seats}${t("seats")}
-
€ ${v.weekend_price_eur || v.daily_price_eur}${t("bpfWeekendRate")}
-
${v.max_daily_km || 150}${t("bpfMaxKm")}
-
+
+
${v.seats}${t("seats")}
+
€ ${v.weekend_price_eur || v.daily_price_eur}${t("bpfWeekendRate")}
+
${v.included_km_per_day || 150}${t("bpfInclKmPerDay")}
+
€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}${t("bpfDeposit")}
@@ -398,29 +398,45 @@ async function updateSidebar() { const vat = price.vat_eur; const total = price.total_eur; const deposit = price.deposit_eur; - const kmPerWeekday = price.max_daily_km; - const kmPerWeekendDay = price.max_km_weekend; - const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay); + const includedKmPerDay = price.included_km_per_day || 150; + const includedKm = totalDays * includedKmPerDay; const photoUrl = optimizedVehiclePhotoUrl(v.photo_url); - bpfSidebarPlaceholder.style.display = "none"; - bpfSidebarContent.style.display = "block"; - bpfSidebarContent.innerHTML = ` -

${t("bpfPriceOverview")}

-
${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}
- ${weekdays > 0 ? `
${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})€ ${weekdayCost.toLocaleString("de-DE")}
` : ""} - ${weekendDays > 0 ? `
${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})€ ${weekendCost.toLocaleString("de-DE")}
` : ""} -
${t("bpfSubtotal")}€ ${subtotal.toLocaleString("de-DE")}
-
${t("bpfVat")}€ ${vat.toLocaleString("de-DE")}
-
${t("bpfTotal")}€ ${total.toLocaleString("de-DE")}
-
${t("bpfDeposit")}€ ${deposit.toLocaleString("de-DE")}
-
${t("bpfIncludedKm")}${includedKm} km
-
${t("bpfExtraKm")}€ 1,50${t("bpfPerKm")}
-
-

${escapeHtml(v.brand)} ${escapeHtml(v.model)}

-

${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}

- `; -} + if (totalDays > 2) { + // Individuell mode: show info banner instead of pricing + bpfSidebarPlaceholder.style.display = "none"; + bpfSidebarContent.style.display = "block"; + bpfSidebarContent.innerHTML = ` +

${t("bpfPriceOverview")}

+
+

${t("bpfIndividuellTitle")}

+

${t("bpfIndividuellDesc")}

+
+
+

${escapeHtml(v.brand)} ${escapeHtml(v.model)}

+

${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}

+ `; + } else { + bpfSidebarPlaceholder.style.display = "none"; + bpfSidebarContent.style.display = "block"; + bpfSidebarContent.innerHTML = ` +

${t("bpfPriceOverview")}

+
${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}
+ ${weekdays > 0 ? `
${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})€ ${weekdayCost.toLocaleString("de-DE")}
` : ""} + ${weekendDays > 0 ? `
${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})€ ${weekendCost.toLocaleString("de-DE")}
` : ""} +
${t("bpfSubtotal")}€ ${subtotal.toLocaleString("de-DE")}
+
${t("bpfVat")}€ ${vat.toLocaleString("de-DE")}
+
${t("bpfTotal")}€ ${total.toLocaleString("de-DE")}
+
${t("bpfDeposit")}€ ${deposit.toLocaleString("de-DE")}
+
${t("bpfIncludedKm")}${includedKm} km
+
${t("bpfExtraKm")}€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}
+
+

${escapeHtml(v.brand)} ${escapeHtml(v.model)}

+

${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}

+ `; + } + + } bpfCar.addEventListener("change", updateSidebar); bpfFrom.addEventListener("change", updateSidebar); diff --git a/frontend/i18n.js b/frontend/i18n.js index 3d3d45e..c4debf7 100644 --- a/frontend/i18n.js +++ b/frontend/i18n.js @@ -89,6 +89,7 @@ export const translations = { bpfWeekendRate: "Wochenendmiete", bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00", bpfMaxKm: "Max. km/Tag", + bpfInclKmPerDay: "Inkl. km/Tag", bpfExtraKm: "Extra km", bpfPriceOverview: "Preisübersicht", bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht", @@ -176,7 +177,7 @@ export const translations = { adminVehicleTab: "Fahrzeug", adminPeriod: "Zeitraum", adminKaution: "Kaution (€)", - adminMaxKmWeekend: "Max. km/Wochenendtag", + adminMaxKmWeekend: "Inkl. km/Wochenende", adminTotalPrice: "Gesamtbetrag", adminLifetimeValueCol: "Gesamtwert", adminTabGeneral: "Allgemein", @@ -222,6 +223,8 @@ export const translations = { adminVatLabelEn: "VAT (20%)", adminTotalLabel: "Gesamtbetrag", adminTotalLabelEn: "Total", + adminInclVat: "inkl. MwSt.", + adminInclVatEn: "incl. VAT", adminDepositLabel: "Kaution", adminDepositLabelEn: "Deposit", adminIncludedKmLabel: "Inkl. km", @@ -231,9 +234,20 @@ export const translations = { adminFirstContacted: "Erster Kontakt", adminFirstContactedEn: "First contacted", adminNote: "Notiz", - adminNoteEn: "Note", + adminNoteEn: "Note", adminSave: "Speichern", adminSaveEn: "Save", + adminPricePerKm: "Preis extra km (€)", + adminRentalType: "Miettyp", + rentalTypeWeekend: "Wochenende", + rentalTypeIndividuell: "Individuell", + adminSortOrder: "Ordnung", + adminEmailSent: "E-Mail gesendet", + sendEmailButton: "E-Mail senden", + emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...", + emailAlreadySent: "Bereits gesendet", + bpfIndividuellTitle: "Individuelle Mietdauer", + bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.", }, en: { navCars: "Fleet", @@ -323,7 +337,8 @@ export const translations = { bpfDailyRate: "Daily rate", bpfWeekendRate: "Weekend rate", bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM", - bpfMaxKm: "Max. km/day", + bpfMaxKm: "Max. km/day", + bpfInclKmPerDay: "Included km/day", bpfExtraKm: "Extra km", bpfPriceOverview: "Price overview", bpfSelectForPrice: "Select vehicle and date for a price overview", @@ -411,7 +426,7 @@ export const translations = { adminVehicleTab: "Vehicle", adminPeriod: "Period", adminKaution: "Deposit (€)", - adminMaxKmWeekend: "Max. km/weekend day", + adminMaxKmWeekend: "Included km/weekend", adminTotalPrice: "Total", adminLifetimeValueCol: "Lifetime", adminTabGeneral: "General", @@ -457,6 +472,8 @@ export const translations = { adminVatLabelEn: "MwSt. (20%)", adminTotalLabel: "Total", adminTotalLabelEn: "Gesamtbetrag", + adminInclVat: "incl. VAT", + adminInclVatEn: "inkl. MwSt.", adminDepositLabel: "Deposit", adminDepositLabelEn: "Kaution", adminIncludedKmLabel: "Included km", @@ -469,6 +486,17 @@ export const translations = { adminNoteEn: "Notiz", adminSave: "Save", adminSaveEn: "Speichern", + adminPricePerKm: "Extra km price (€)", + adminRentalType: "Rental type", + rentalTypeWeekend: "Weekend", + rentalTypeIndividuell: "Custom", + adminSortOrder: "Order", + adminEmailSent: "Email sent", + sendEmailButton: "Send Email", + emailSentToast: "Email is being prepared and will be sent shortly...", + emailAlreadySent: "Already sent", + bpfIndividuellTitle: "Custom Rental Duration", + bpfIndividuellDesc: "For rental periods over 2 days, we'll create a personalized quote. We'll check availability and get back to you via email shortly.", }, }; diff --git a/frontend/index.html b/frontend/index.html index 453baff..ae0e9c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -113,7 +113,6 @@
- -
- Impressum - AGB - Mietbedingungen - Datenschutz -

Warum MC Cars

-

Keine Kompromisse zwischen Sicherheit und Fahrspaß.

- - - -
-
-
🛡
-

Versicherungsschutz

-

Vollkasko mit klarem Selbstbehalt.

-
-
-
-

Premium Flotte

-

Handverlesene Performance-Modelle.

-
-
-
-

Faire Kaution

-

Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.

-
-
- -
-
@@ -409,8 +377,7 @@

Navigation

Fahrzeuge - Warum wir - Buchen + Buchen
@@ -447,6 +414,6 @@
- + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a07ec59..6364ef8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -20,10 +20,9 @@ server { add_header Cache-Control "public"; try_files $uri =404; } - # CSS/JS: 1-week cache; config.js is excluded by its exact-match rule above. + # CSS/JS: no cache to prevent stale content during development location ~* \.(?:css|js)$ { - expires 7d; - add_header Cache-Control "public, max-age=604800"; + add_header Cache-Control "no-store"; try_files $uri =404; } diff --git a/frontend/styles.css b/frontend/styles.css index e1ce580..92648f3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -904,7 +904,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); } /* ---------------- Admin ---------------- */ .admin-page { - max-width: 1100px; + max-width: 1280px; margin: 2rem auto; padding: 0 1rem; } @@ -960,6 +960,7 @@ table.admin-table th, table.admin-table td { text-align: left; padding: 0.75rem 0.6rem; border-bottom: 1px solid var(--line); + vertical-align: top; transition: background-color 0.2s ease; } table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; } @@ -969,6 +970,9 @@ table.admin-table tbody tr:hover { transform: translateX(4px); } +/* Admin table actions column: prevent button wrap */ +table.admin-table td:last-child { white-space: nowrap; } + .link-lead { text-decoration: none; cursor: pointer; } .link-lead:hover code { color: var(--accent-strong); text-decoration: underline; } @@ -1098,6 +1102,9 @@ input:checked + .toggle-slider:before { .pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); } .pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); } .pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; } +.pill-single_day { background: rgba(74, 144, 226, 0.16); color: #8abfff; border: 1px solid rgba(74, 144, 226, 0.35); } +.pill-weekend { background: rgba(200, 150, 80, 0.15); color: #e4b676; border: 1px solid rgba(200, 150, 80, 0.3); } +.pill-individuell { background: rgba(204, 116, 58, 0.16); color: #ffb487; border: 1px solid rgba(204, 116, 58, 0.38); } .muted { color: var(--muted); } @@ -1107,7 +1114,8 @@ input:checked + .toggle-slider:before { /* Dialog */ dialog#leadDialog, -dialog#customerDialog { +dialog#customerDialog, +dialog#orderDialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-card); color: var(--text); padding: 0; max-width: 640px; width: 94%; @@ -1115,11 +1123,13 @@ dialog#customerDialog { transition: opacity 0.3s ease, transform 0.3s ease; } dialog#leadDialog[open], -dialog#customerDialog[open] { +dialog#customerDialog[open], +dialog#orderDialog[open] { animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; } dialog#leadDialog::backdrop, -dialog#customerDialog::backdrop { +dialog#customerDialog::backdrop, +dialog#orderDialog::backdrop { background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); animation: fadeIn 0.3s ease forwards; diff --git a/n8n/bootstrap/bootstrap-n8n.sh b/n8n/bootstrap/bootstrap-n8n.sh index 633026b..2313b45 100644 --- a/n8n/bootstrap/bootstrap-n8n.sh +++ b/n8n/bootstrap/bootstrap-n8n.sh @@ -3,6 +3,8 @@ set -eu WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}" WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.json" +WORKFLOW03_TEMPLATE="/opt/mc-cars/workflows/03-manual-email-send.json" +WORKFLOW03_RENDERED="/tmp/03-manual-email-send.rendered.json" CREDENTIALS_FILE="/tmp/mc-cars-credentials.json" required_var() { @@ -74,10 +76,28 @@ sed \ echo "[n8n-bootstrap] Importing credentials" n8n import:credentials --input="$CREDENTIALS_FILE" -echo "[n8n-bootstrap] Importing workflow" +echo "[n8n-bootstrap] Importing workflow 01" n8n import:workflow --input="$WORKFLOW_RENDERED" -echo "[n8n-bootstrap] Activating workflow ${N8N_PAYMENT_WORKFLOW_ID}" -n8n update:workflow --id="${N8N_PAYMENT_WORKFLOW_ID}" --active=true +# Process and import workflow 03 - Manual Email Send +if [ -f "$WORKFLOW03_TEMPLATE" ]; then + sed \ + -e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \ + -e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \ + -e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \ + -e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \ + "$WORKFLOW03_TEMPLATE" > "$WORKFLOW03_RENDERED" + + echo "[n8n-bootstrap] Importing workflow 03 (Manual Email Send)" + n8n import:workflow --input="$WORKFLOW03_RENDERED" +fi + +# Publish all imported workflows so they appear in the UI +echo "[n8n-bootstrap] Publishing all workflows" +WF_IDS=$(n8n list:workflow 2>/dev/null | cut -d'|' -f1 || true) +for wfid in $WF_IDS; do + echo "[n8n-bootstrap] Publishing workflow $wfid" + n8n publish:workflow --id="$wfid" 2>/dev/null || true +done echo "[n8n-bootstrap] Bootstrap complete" diff --git a/n8n/workflows/01-qualification-payment-email.json b/n8n/workflows/01-qualification-payment-email.json index 55c072b..b90b57d 100644 --- a/n8n/workflows/01-qualification-payment-email.json +++ b/n8n/workflows/01-qualification-payment-email.json @@ -21,10 +21,7 @@ "name": "Postgres Trigger", "type": "n8n-nodes-base.postgresTrigger", "typeVersion": 1, - "position": [ - 0, - 0 - ], + "position": [0, 0], "credentials": { "postgres": { "id": "__POSTGRES_CREDENTIAL_ID__", @@ -35,17 +32,14 @@ { "parameters": { "operation": "executeQuery", - "query": "SELECT c.id, c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $json.payload.id }}'::uuid", + "query": "SELECT so.id, c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count,\n so.rental_type\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $json.payload.id }}'::uuid\n AND coalesce(lower(trim(so.rental_type)), 'weekend') NOT IN ('individuell','individual','custom')", "options": {} }, "id": "ca4ca61e-fea9-4044-9586-216af016cb2e", "name": "Fetch Order Data", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, - "position": [ - 224, - 0 - ], + "position": [224, 0], "credentials": { "postgres": { "id": "__POSTGRES_CREDENTIAL_ID__", @@ -56,16 +50,13 @@ { "parameters": { "mode": "runOnceForEachItem", - "jsCode": "const item = $json;\n\nconst formatEur = (value) => {\n const n = Number(value || 0);\n return new Intl.NumberFormat(\"de-AT\", { style: \"currency\", currency: \"EUR\" }).format(n);\n};\n\nconst formatDate = (value) => {\n if (!value) return \"-\";\n const d = new Date(value);\n if (Number.isNaN(d.getTime())) return String(value);\n return new Intl.DateTimeFormat(\"de-AT\", { day: \"2-digit\", month: \"2-digit\", year: \"numeric\" }).format(d);\n};\n\nconst orderNumber = item.order_number || \"\";\nconst dateFrom = formatDate(item.date_from);\nconst dateTo = formatDate(item.date_to);\nconst rentalRange = `${dateFrom} bis ${dateTo}`;\n\nconst depositEur = formatEur(item.deposit_eur);\nconst rentalEur = formatEur(item.total_eur);\nconst subtotalEur = formatEur(item.subtotal_eur);\nconst vatEur = formatEur(item.vat_eur);\n\nconst safeName = item.name || \"Kundin/Kunde\";\nconst vehicle = item.vehicle_label || \"-\";\n\nconst subject = `MC Cars – Buchung bestätigt (${orderNumber}) – Zahlungsinformationen`;\n\nconst paypalButton = (url, alt) => `\n \n \"${alt}\"\n \n`;\n\nconst html = `\n
\n \n \n \n \n \n \n \n
\n
MC Cars
\n

Ihre Miete wurde freigegeben

\n
\n

Guten Tag ${safeName},

\n

\n Ihre Buchung wurde auf Basis der von Ihnen bereitgestellten Informationen geprüft und freigegeben.\n Nachfolgend finden Sie die Zahlungsanweisungen für Kaution und Mietbetrag.\n

\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Bestellnummer${orderNumber}
Fahrzeug${vehicle}
Mietzeitraum${rentalRange}
Zwischensumme${subtotalEur}
MwSt.${vatEur}
Mietbetrag${rentalEur}
Kaution${depositEur}
\n\n

1) Kaution bezahlen

\n

Sie können die Kaution in Höhe von ${depositEur} per PayPal bezahlen:

\n ${paypalButton('__PAYPAL_KAUTION_LINK__', 'PayPal Kaution bezahlen')}\n

Alternativ ist Barzahlung möglich. In diesem Fall antworten Sie bitte so schnell wie möglich auf diese E-Mail, damit wir die Übergabe bestätigen können.

\n\n

2) Mietbetrag bezahlen

\n

Den Mietbetrag von ${rentalEur} können Sie über folgenden PayPal-Button bezahlen:

\n ${paypalButton('__PAYPAL_MIETE_LINK__', 'PayPal Mietbetrag bezahlen')}\n\n

Falls Sie Fragen haben, antworten Sie einfach auf diese E-Mail.

\n

Freundliche Grüße
MC Cars Team

\n
\n
`;\n\nreturn {\n toEmail: item.email,\n subject,\n html,\n};" + "jsCode": "const item = $json;\n\nconst formatEur = (value) => {\n const n = Number(value || 0);\n return new Intl.NumberFormat(\"de-AT\", { style: \"currency\", currency: \"EUR\" }).format(n);\n};\n\nconst formatDate = (value) => {\n if (!value) return \"-\";\n const d = new Date(value);\n if (Number.isNaN(d.getTime())) return String(value);\n return new Intl.DateTimeFormat(\"de-AT\", { day: \"2-digit\", month: \"2-digit\", year: \"numeric\" }).format(d);\n};\n\nconst orderNumber = item.order_number || \"\";\nconst dateFrom = formatDate(item.date_from);\nconst dateTo = formatDate(item.date_to);\nconst rentalRange = `${dateFrom} bis ${dateTo}`;\n\nconst depositEur = formatEur(item.deposit_eur);\nconst rentalEur = formatEur(item.total_eur);\nconst subtotalEur = formatEur(item.subtotal_eur);\nconst vatEur = formatEur(item.vat_eur);\n\nconst safeName = item.name || \"Kundin/Kunde\";\nconst vehicle = item.vehicle_label || \"-\";\n\nconst subject = `MC Cars – Buchung bestätigt (${orderNumber}) – Zahlungsinformationen`;\n\nconst paypalButton = (url, alt) => `\n \n \"${alt}\"\n \n`;\n\nconst html = `\n
\n \n \n \n \n \n \n \n
\n
MC Cars
\n

Ihre Miete wurde freigegeben

\n
\n

Guten Tag ${safeName},

\n

\n Ihre Buchung wurde auf Basis der von Ihnen bereitgestellten Informationen geprüft und freigegeben.\n Nachfolgend finden Sie die Zahlungsanweisungen für Kaution und Mietbetrag.\n

\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
Bestellnummer${orderNumber}
Fahrzeug${vehicle}
Mietzeitraum${rentalRange}
Zwischensumme${subtotalEur}
MwSt.${vatEur}
Mietbetrag${rentalEur}
Kaution${depositEur}
\n\n

1) Kaution bezahlen

\n

Sie können die Kaution in Höhe von ${depositEur} per PayPal bezahlen:

\n ${paypalButton('__PAYPAL_KAUTION_LINK__', 'PayPal Kaution bezahlen')}\n

Alternativ ist Barzahlung möglich. In diesem Fall antworten Sie bitte so schnell wie möglich auf diese E-Mail, damit wir die Übergabe bestätigen können.

\n\n

2) Mietbetrag bezahlen

\n

Den Mietbetrag von ${rentalEur} können Sie über folgenden PayPal-Button bezahlen:

\n ${paypalButton('__PAYPAL_MIETE_LINK__', 'PayPal Mietbetrag bezahlen')}\n\n

Falls Sie Fragen haben, antworten Sie einfach auf diese E-Mail.

\n

Freundliche Grüße
MC Cars Team

\n
\n
`;\n\nreturn {\n toEmail: item.email,\n subject,\n html,\n};\n" }, "id": "a39ba066-d093-4444-bd45-6a295d599637", "name": "Build Payment Email", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [ - 448, - 0 - ] + "position": [448, 0] }, { "parameters": { @@ -75,22 +66,84 @@ "html": "={{ $json.html }}", "options": { "appendAttribution": false - } + }, + "continueOnFail": true }, "id": "c537fb83-bc97-4015-b9fe-5ff3ca8b2c97", "name": "Send Payment Email", "type": "n8n-nodes-base.emailSend", "typeVersion": 2.1, - "position": [ - 672, - 0 - ], + "position": [672, 0], "credentials": { "smtp": { "id": "__SMTP_CREDENTIAL_ID__", "name": "SMTP account" } } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "UPDATE public.sales_orders SET email_sent = 1, updated_at = now() WHERE id = '{{ $('Fetch Order Data').item.json.id }}'::uuid", + "options": {} + }, + "id": "update-email-sent-1", + "name": "Update email_sent = 1", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1024, -100], + "credentials": { + "postgres": { + "id": "__POSTGRES_CREDENTIAL_ID__", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "UPDATE public.sales_orders SET email_sent = 2, updated_at = now() WHERE id = '{{ $('Fetch Order Data').item.json.id }}'::uuid", + "options": {} + }, + "id": "update-email-sent-2", + "name": "Update email_sent = 2", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1024, 100], + "credentials": { + "postgres": { + "id": "__POSTGRES_CREDENTIAL_ID__", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-email-error", + "leftValue": "={{ $json.error }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notExists" + } + } + ], + "combinator": "and" + } + }, + "id": "check-email-error", + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [896, 0] } ], "pinData": {}, @@ -127,6 +180,35 @@ } ] ] + }, + "Send Payment Email": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "Update email_sent = 1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Update email_sent = 2", + "type": "main", + "index": 0 + } + ] + ] } }, "active": true, @@ -134,11 +216,11 @@ "executionOrder": "v1", "binaryMode": "separate" }, - "versionId": "814e8f72-95dc-4097-bc94-76fe437448ee", + "versionId": "814e8f72-95dc-4097-bc94-76fe437448ef", "meta": { "templateCredsSetupCompleted": true }, - "id": "rI1gUpcRXSikxWhh", + "id": "rI1gUpcRXSikxWhi", "tags": [ { "name": "mc-cars" diff --git a/n8n/workflows/03-manual-email-send.json b/n8n/workflows/03-manual-email-send.json new file mode 100644 index 0000000..89b24ee --- /dev/null +++ b/n8n/workflows/03-manual-email-send.json @@ -0,0 +1,262 @@ +{ + "name": "Manual Email Send", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "manual-email-send", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-trigger", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [250, 300], + "webhookId": "manual-email-send" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT so.id, c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $json.body?.sales_order_id || $json.query?.sales_order_id || $json.sales_order_id }}'::uuid", + "options": {} + }, + "id": "fetch-order-data", + "name": "Fetch Order Data", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [470, 300], + "credentials": { + "postgres": { + "id": "__POSTGRES_CREDENTIAL_ID__", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const item = $json;\n\nconst formatEur = (value) => {\n const n = Number(value || 0);\n return new Intl.NumberFormat(\"de-AT\", { style: \"currency\", currency: \"EUR\" }).format(n);\n};\n\nconst formatDate = (value) => {\n if (!value) return \"-\";\n const d = new Date(value);\n if (Number.isNaN(d.getTime())) return String(value);\n return new Intl.DateTimeFormat(\"de-AT\", { day: \"2-digit\", month: \"2-digit\", year: \"numeric\" }).format(d);\n};\n\nconst toNum = (value) => {\n const n = Number(value);\n return Number.isFinite(n) ? n : 0;\n};\n\nconst round2 = (value) => Math.round(value * 100) / 100;\n\nconst orderNumber = item.order_number || \"\";\nconst dateFrom = formatDate(item.date_from);\nconst dateTo = formatDate(item.date_to);\nconst rentalRange = `${dateFrom} bis ${dateTo}`;\n\nconst totalEurNum = toNum(item.total_eur);\nlet subtotalEurNum = toNum(item.subtotal_eur);\nlet vatEurNum = toNum(item.vat_eur);\n\nif (totalEurNum > 0 && subtotalEurNum === 0 && vatEurNum === 0) {\n subtotalEurNum = round2(totalEurNum / 1.2);\n vatEurNum = round2(totalEurNum - subtotalEurNum);\n}\n\nconst depositEur = formatEur(item.deposit_eur);\nconst rentalEur = formatEur(totalEurNum);\nconst subtotalEur = formatEur(subtotalEurNum);\nconst vatEur = formatEur(vatEurNum);\n\nconst safeName = item.name || \"Kundin/Kunde\";\nconst vehicle = item.vehicle_label || \"-\";\n\nconst subject = `MC Cars \u2013 Buchung best\u00e4tigt (${orderNumber}) \u2013 Zahlungsinformationen`;\n\nconst paypalButton = (url, alt) => `\n \n \"${alt}\"\n \n`;\n\nconst html = `\n
\n \n \n \n \n \n \n \n
\n
MC Cars
\n

Ihre Miete wurde freigegeben

\n
\n

Guten Tag ${safeName},

\n

\n Ihre Buchung wurde auf Basis der von Ihnen bereitgestellten Informationen gepr\u00fcft und freigegeben.\n Nachfolgend finden Sie die Zahlungsanweisungen f\u00fcr Kaution und Mietbetrag.\n

\n\n \n \n \n \n \n \n \n \n
Bestellnummer${orderNumber}
Fahrzeug${vehicle}
Mietzeitraum${rentalRange}
Zwischensumme${subtotalEur}
MwSt.${vatEur}
Mietbetrag${rentalEur}
Kaution${depositEur}
\n\n

1) Kaution bezahlen

\n

Sie k\u00f6nnen die Kaution in H\u00f6he von ${depositEur} per PayPal bezahlen:

\n ${paypalButton('__PAYPAL_KAUTION_LINK__', 'PayPal Kaution bezahlen')}\n

Alternativ ist Barzahlung m\u00f6glich. In diesem Fall antworten Sie bitte so schnell wie m\u00f6glich auf diese E-Mail, damit wir die \u00dcbergabe best\u00e4tigen k\u00f6nnen.

\n\n

2) Mietbetrag bezahlen

\n

Den Mietbetrag von ${rentalEur} k\u00f6nnen Sie \u00fcber folgenden PayPal-Button bezahlen:

\n ${paypalButton('__PAYPAL_MIETE_LINK__', 'PayPal Mietbetrag bezahlen')}\n\n

Falls Sie Fragen haben, antworten Sie einfach auf diese E-Mail.

\n

Freundliche Gr\u00fc\u00dfe
MC Cars Team

\n
\n
`;\n\nreturn {\n toEmail: item.email,\n subject,\n html,\n sales_order_id: item.id,\n};\n" + }, + "id": "build-email", + "name": "Build Email", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [690, 300] + }, + { + "parameters": { + "fromEmail": "office@mc-cars.at", + "toEmail": "={{ $json.toEmail }}", + "subject": "={{ $json.subject }}", + "html": "={{ $json.html }}", + "options": { + "appendAttribution": false + }, + "continueOnFail": true + }, + "id": "send-email", + "name": "Send Email", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 2.1, + "position": [910, 300], + "credentials": { + "smtp": { + "id": "__SMTP_CREDENTIAL_ID__", + "name": "SMTP account" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "UPDATE public.sales_orders SET email_sent = 1, updated_at = now() WHERE id = '{{ $('Build Email').item.json.sales_order_id }}'::uuid", + "options": {} + }, + "id": "update-email-1", + "name": "Update email_sent = 1", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1260, 200], + "credentials": { + "postgres": { + "id": "__POSTGRES_CREDENTIAL_ID__", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "UPDATE public.sales_orders SET email_sent = 2, updated_at = now() WHERE id = '{{ $('Build Email').item.json.sales_order_id }}'::uuid", + "options": {} + }, + "id": "update-email-2", + "name": "Update email_sent = 2", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [1260, 400], + "credentials": { + "postgres": { + "id": "__POSTGRES_CREDENTIAL_ID__", + "name": "Postgres account" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-error", + "leftValue": "={{ $json.error }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "notExists" + } + } + ], + "combinator": "and" + } + }, + "id": "check-error", + "name": "IF", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1130, 300] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"ok\": true }", + "options": {} + }, + "id": "respond-ok", + "name": "Respond OK", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1480, 200] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={ \"ok\": false, \"error\": \"email_send_failed\" }", + "options": { + "responseCode": 500 + } + }, + "id": "respond-err", + "name": "Respond Error", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [1480, 400] + } + ], + "pinData": {}, + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Fetch Order Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Order Data": { + "main": [ + [ + { + "node": "Build Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email": { + "main": [ + [ + { + "node": "Send Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Email": { + "main": [ + [ + { + "node": "IF", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF": { + "main": [ + [ + { + "node": "Update email_sent = 1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Update email_sent = 2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update email_sent = 1": { + "main": [ + [ + { + "node": "Respond OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update email_sent = 2": { + "main": [ + [ + { + "node": "Respond Error", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1" + }, + "versionId": "d3c4e5f6-1234-5678-90ab-cdef12345680", + "meta": { + "templateCredsSetupCompleted": true + }, + "id": "M3nU4lW0rkFl0w03", + "tags": [ + { + "name": "mc-cars" + } + ] +} diff --git a/supabase/migrations/11-consolidate-km-rental.sql b/supabase/migrations/11-consolidate-km-rental.sql new file mode 100644 index 0000000..e7468db --- /dev/null +++ b/supabase/migrations/11-consolidate-km-rental.sql @@ -0,0 +1,396 @@ +-- 11-consolidate-km-rental.sql +-- Consolidate km/rental model: new included_km_per_day, rental_type, +-- rewrite calculate_price / create_lead / qualify_lead / notify_lead_qualified, +-- add sales_order_set_total RPC. +-- Idempotent. + +-- ============================================================================= +-- A. Vehicles table changes +-- ============================================================================= + +alter table public.vehicles add column if not exists included_km_per_day integer not null default 150; + +update public.vehicles set included_km_per_day = coalesce(max_daily_km, 150) where included_km_per_day = 150; + +update public.vehicles set included_km_per_day = 200 where brand = 'Ferrari' and model = '296 GTB'; + +alter table public.vehicles add column if not exists price_per_km_eur numeric(10,2) not null default 1.50; + +alter table public.vehicles drop column if exists max_daily_km; +alter table public.vehicles drop column if exists max_km_weekend; + +-- ============================================================================= +-- B. Leads table changes +-- ============================================================================= + +alter table public.leads add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell')); +update public.leads set rental_type = 'weekend' where rental_type is null; +create index if not exists leads_rental_type_idx on public.leads (rental_type); + +-- ============================================================================= +-- C. Sales orders table changes +-- ============================================================================= + +alter table public.sales_orders add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell')); +update public.sales_orders set rental_type = 'weekend' where rental_type is null; +create index if not exists sales_orders_rental_type_idx on public.sales_orders (rental_type); + +-- ============================================================================= +-- D. Rewrite calculate_price() RPC +-- ============================================================================= + +drop function if exists public.calculate_price(uuid, date, date); + +create or replace function public.calculate_price( + p_vehicle_id uuid, + p_date_from date, + p_date_to date +) +returns jsonb +language plpgsql +stable +security definer +as $$ +declare + v_vehicle record; + v_total_days integer; + v_weekend_days integer; + v_weekdays integer; + v_daily_subtotal integer; + v_weekend_subtotal integer; + v_subtotal_eur integer; + v_vat_eur integer; + v_total_eur integer; + v_deposit_eur integer; + v_included_km_per_day integer; + v_price_per_km numeric(10,2); + v_total_included_km integer; + v_extra_km integer; + v_extra_km_eur numeric(10,2); + v_cur date; + v_dow integer; +begin + if p_vehicle_id is null or p_date_from is null or p_date_to is null then + raise exception 'vehicle_id, date_from and date_to are required'; + end if; + if p_date_to <= p_date_from then + raise exception 'date_to must be after date_from'; + end if; + + select daily_price_eur, weekend_price_eur, kaution_eur, included_km_per_day, price_per_km_eur + into v_vehicle + from public.vehicles + where id = p_vehicle_id; + + if not found then + raise exception 'Vehicle not found'; + end if; + + v_total_days := (p_date_to - p_date_from); + v_weekend_days := 0; + v_cur := p_date_from; + while v_cur < p_date_to loop + v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun + if v_dow in (6, 7) then + v_weekend_days := v_weekend_days + 1; + end if; + v_cur := v_cur + 1; + end loop; + v_weekdays := v_total_days - v_weekend_days; + + v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur; + v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end); + v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal; + v_vat_eur := round(v_subtotal_eur * 0.20); + v_total_eur := v_subtotal_eur + v_vat_eur; + v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000); + + v_included_km_per_day := coalesce(v_vehicle.included_km_per_day, 150); + v_total_included_km := v_total_days * v_included_km_per_day; + v_price_per_km := coalesce(v_vehicle.price_per_km_eur, 1.50); + v_extra_km := greatest(0, 0); -- extra km is determined by caller (frontend) based on expected usage + v_extra_km_eur := v_extra_km * v_price_per_km; + + return jsonb_build_object( + 'total_days', v_total_days, + 'weekday_count', v_weekdays, + 'weekend_day_count', v_weekend_days, + 'daily_subtotal', v_daily_subtotal, + 'weekend_subtotal', v_weekend_subtotal, + 'subtotal_eur', v_subtotal_eur, + 'vat_eur', v_vat_eur, + 'total_eur', v_total_eur, + 'deposit_eur', v_deposit_eur, + 'daily_price_eur', v_vehicle.daily_price_eur, + 'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end), + 'included_km_per_day', v_included_km_per_day, + 'total_included_km', v_total_included_km, + 'price_per_km_eur', v_price_per_km, + 'extra_km', v_extra_km, + 'extra_km_eur', v_extra_km_eur + ); +end; +$$; + +grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role; + +-- ============================================================================= +-- E. Rewrite create_lead() RPC +-- ============================================================================= + +drop function if exists public.create_lead( + text, text, text, uuid, text, date, date, text, text +); +drop function if exists public.create_lead( + text, text, text, uuid, text, date, date, text, text, + integer, integer, integer, integer, integer, integer, integer, integer, integer, + text, text +); + +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_ip_address text default '', + p_ip_country text default '' +) +returns uuid +language plpgsql +security definer +as $$ +declare + v_lead_id uuid; + v_vehicle record; + v_total_days integer := 0; + v_weekend_days integer := 0; + v_weekdays integer := 0; + v_daily_subtotal integer := 0; + v_weekend_subtotal integer := 0; + v_subtotal_eur integer := 0; + v_vat_eur integer := 0; + v_total_eur integer := 0; + v_deposit_eur integer := 0; + v_rental_type text := 'weekend'; + v_cur date; + v_dow integer; +begin + if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then + select daily_price_eur, weekend_price_eur, kaution_eur + into v_vehicle + from public.vehicles + where id = p_vehicle_id; + + if found then + v_total_days := (p_date_to - p_date_from); + + -- Auto-detect rental type: 2 days or less = weekend, more = individuell + v_rental_type := 'weekend'; + if v_total_days > 2 then + v_rental_type := 'individuell'; + end if; + + -- For individuell, set all pricing to 0 + if v_rental_type = 'individuell' then + v_daily_subtotal := 0; + v_weekend_subtotal := 0; + v_subtotal_eur := 0; + v_vat_eur := 0; + v_total_eur := 0; + v_deposit_eur := 0; + else + v_cur := p_date_from; + while v_cur < p_date_to loop + v_dow := extract(isodow from v_cur); + if v_dow in (6, 7) then + v_weekend_days := v_weekend_days + 1; + end if; + v_cur := v_cur + 1; + end loop; + v_weekdays := v_total_days - v_weekend_days; + + v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur; + v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end); + v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal; + v_vat_eur := round(v_subtotal_eur * 0.20); + v_total_eur := v_subtotal_eur + v_vat_eur; + v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000); + end if; + end if; + end if; + + 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, ip_address, ip_country, + rental_type + ) values ( + p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to, + p_message, p_source, + v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur, + v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country, + v_rental_type + ) + returning id into v_lead_id; + return v_lead_id; +end; +$$; + +grant execute on function public.create_lead( + text, text, text, uuid, text, date, date, text, text, text, text +) to anon, authenticated, service_role; + +-- ============================================================================= +-- F. Rewrite qualify_lead() RPC +-- ============================================================================= + +create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '') +returns public.customers +language plpgsql +security invoker +as $$ +declare + v_lead public.leads; + v_customer public.customers; + v_sales_order public.sales_orders; + v_user uuid := auth.uid(); + v_order_num text; + v_year integer; + v_count integer; +begin + select * into v_lead from public.leads where id = p_lead_id for update; + if not found then + raise exception 'lead % not found', p_lead_id; + end if; + + if v_lead.status = 'qualified' then + select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1; + return v_customer; + end if; + + update public.leads + set status = 'qualified', + is_active = false, + qualified_at = now(), + qualified_by = v_user, + admin_notes = coalesce(nullif(p_notes, ''), admin_notes) + where id = v_lead.id; + + insert into public.customers (lead_id, name, email, phone, notes, created_by) + values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user) + on conflict ((lower(email))) do update + set name = excluded.name, + phone = excluded.phone, + notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end, + updated_at = now() + returning * into v_customer; + + v_year := extract(year from now())::integer; + select coalesce(count(*), 0) + 1 into v_count + from public.sales_orders + where extract(year from created_at)::integer = v_year; + v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0'); + + insert into public.sales_orders ( + customer_id, lead_id, order_number, private_notes, + daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, + total_days, weekday_count, weekend_day_count, + date_from, date_to, vehicle_label, rental_type + ) values ( + v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''), + coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0), + coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0), + coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0), + coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0), + coalesce(v_lead.weekend_day_count, 0), + v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type + ) returning * into v_sales_order; + + insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) + select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at + from public.lead_attachments la + where la.lead_id = v_lead.id + and not exists ( + select 1 from public.customer_attachments ca + where ca.customer_id = v_customer.id + and ca.file_path = la.file_path + ); + + insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) + select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at + from public.lead_attachments la + where la.lead_id = v_lead.id; + + return v_customer; +end; +$$; + +-- ============================================================================= +-- G. Rewrite notify_lead_qualified() trigger function +-- ============================================================================= + +create or replace function public.notify_lead_qualified() +returns trigger +language plpgsql +security definer +as $$ +begin + -- Skip notification for 'individuell' rental type + if NEW.rental_type = 'individuell' then + return NEW; + end if; + + perform pg_notify('lead_qualified', json_build_object( + 'sales_order_id', NEW.id, + 'customer_id', NEW.customer_id, + 'lead_id', NEW.lead_id, + 'order_number', NEW.order_number, + 'total_eur', NEW.total_eur, + 'deposit_eur', NEW.deposit_eur, + 'date_from', NEW.date_from, + 'date_to', NEW.date_to, + 'vehicle_label', NEW.vehicle_label, + 'rental_type', NEW.rental_type + )::text); + return NEW; +end; +$$; + +-- ============================================================================= +-- H. New RPC: sales_order_set_total +-- ============================================================================= + +create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer) +returns void +language plpgsql +security invoker +as $$ +declare + v_so public.sales_orders; +begin + select * into v_so from public.sales_orders where id = p_so_id for update; + if not found then + raise exception 'sales order % not found', p_so_id; + end if; + if v_so.rental_type != 'individuell' then + raise exception 'can only set total for individuell orders'; + end if; + update public.sales_orders + set total_eur = p_total_eur, updated_at = now() + where id = p_so_id; +end; +$$; + +grant execute on function public.sales_order_set_total(uuid, integer) to authenticated; + +-- ============================================================================= +-- I. Final schema reload +-- ============================================================================= + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/12-email-sent-and-more.sql b/supabase/migrations/12-email-sent-and-more.sql new file mode 100644 index 0000000..dd1522d --- /dev/null +++ b/supabase/migrations/12-email-sent-and-more.sql @@ -0,0 +1,209 @@ +-- 12-email-sent-and-more.sql +-- Add email_sent column to sales_orders, update notify_lead_qualified() to include +-- rental_type and email_sent, update qualify_lead() to set email_sent=0, +-- add sales_order_update_email_sent and sales_order_get_email_details RPCs. +-- Idempotent. + +-- ============================================================================= +-- A. Add email_sent to sales_orders +-- ============================================================================= + +alter table public.sales_orders add column if not exists email_sent integer not null default 0; +create index if not exists sales_orders_email_sent_idx on public.sales_orders (email_sent); + +-- ============================================================================= +-- B. Update notify_lead_qualified() trigger function +-- (defined in 10-mietvertrag-workflow.sql, overridden by 11-consolidate-km-rental.sql) +-- Since migration 12 runs after 11, this is the final version. +-- ============================================================================= + +create or replace function public.notify_lead_qualified() +returns trigger +language plpgsql +security definer +as $$ +begin + -- Skip notification for 'individuell' rental type + if NEW.rental_type = 'individuell' then + return NEW; + end if; + + perform pg_notify('lead_qualified', json_build_object( + 'sales_order_id', NEW.id, + 'customer_id', NEW.customer_id, + 'lead_id', NEW.lead_id, + 'order_number', NEW.order_number, + 'total_eur', NEW.total_eur, + 'deposit_eur', NEW.deposit_eur, + 'date_from', NEW.date_from, + 'date_to', NEW.date_to, + 'vehicle_label', NEW.vehicle_label, + 'rental_type', NEW.rental_type, + 'email_sent', NEW.email_sent + )::text); + return NEW; +end; +$$; + +-- ============================================================================= +-- C. Update qualify_lead() RPC +-- Add email_sent = 0 to the sales_orders insert. +-- ============================================================================= + +create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '') +returns public.customers +language plpgsql +security invoker +as $$ +declare + v_lead public.leads; + v_customer public.customers; + v_sales_order public.sales_orders; + v_user uuid := auth.uid(); + v_order_num text; + v_year integer; + v_count integer; +begin + select * into v_lead from public.leads where id = p_lead_id for update; + if not found then + raise exception 'lead % not found', p_lead_id; + end if; + + if v_lead.status = 'qualified' then + select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1; + return v_customer; + end if; + + update public.leads + set status = 'qualified', + is_active = false, + qualified_at = now(), + qualified_by = v_user, + admin_notes = coalesce(nullif(p_notes, ''), admin_notes) + where id = v_lead.id; + + insert into public.customers (lead_id, name, email, phone, notes, created_by) + values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user) + on conflict ((lower(email))) do update + set name = excluded.name, + phone = excluded.phone, + notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end, + updated_at = now() + returning * into v_customer; + + v_year := extract(year from now())::integer; + select coalesce(count(*), 0) + 1 into v_count + from public.sales_orders + where extract(year from created_at)::integer = v_year; + v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0'); + + insert into public.sales_orders ( + customer_id, lead_id, order_number, private_notes, + daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, + total_days, weekday_count, weekend_day_count, + date_from, date_to, vehicle_label, rental_type, email_sent + ) values ( + v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''), + coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0), + coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0), + coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0), + coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0), + coalesce(v_lead.weekend_day_count, 0), + v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type, 0 + ) returning * into v_sales_order; + + insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) + select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at + from public.lead_attachments la + where la.lead_id = v_lead.id + and not exists ( + select 1 from public.customer_attachments ca + where ca.customer_id = v_customer.id + and ca.file_path = la.file_path + ); + + insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) + select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at + from public.lead_attachments la + where la.lead_id = v_lead.id; + + return v_customer; +end; +$$; + +-- ============================================================================= +-- D. New RPC: sales_order_update_email_sent +-- ============================================================================= + +create or replace function public.sales_order_update_email_sent(p_so_id uuid, p_status integer) +returns void +language plpgsql +security invoker +as $$ +declare + v_so public.sales_orders; +begin + select * into v_so from public.sales_orders where id = p_so_id for update; + if not found then + raise exception 'sales order % not found', p_so_id; + end if; + if p_status not in (0, 1, 2) then + raise exception 'invalid email_sent status: %', p_status; + end if; + update public.sales_orders + set email_sent = p_status, updated_at = now() + where id = p_so_id; +end; +$$; + +grant execute on function public.sales_order_update_email_sent(uuid, integer) to authenticated; + +-- ============================================================================= +-- E. New RPC: sales_order_get_email_details +-- ============================================================================= + +create or replace function public.sales_order_get_email_details(p_so_id uuid) +returns jsonb +language plpgsql +security definer +as $$ +declare + v_result jsonb; +begin + select jsonb_build_object( + 'order_number', so.order_number, + 'total_eur', so.total_eur, + 'deposit_eur', so.deposit_eur, + 'date_from', so.date_from, + 'date_to', so.date_to, + 'vehicle_label', so.vehicle_label, + 'customer_name', c.name, + 'customer_email', c.email, + 'customer_phone', c.phone, + 'daily_subtotal', so.daily_subtotal, + 'weekend_subtotal', so.weekend_subtotal, + 'subtotal_eur', so.subtotal_eur, + 'vat_eur', so.vat_eur, + 'total_days', so.total_days, + 'weekday_count', so.weekday_count, + 'weekend_day_count', so.weekend_day_count + ) into v_result + from public.sales_orders so + join public.customers c on c.id = so.customer_id + where so.id = p_so_id; + + if v_result is null then + raise exception 'sales order % not found', p_so_id; + end if; + + return v_result; +end; +$$; + +grant execute on function public.sales_order_get_email_details(uuid) to authenticated; + +-- ============================================================================= +-- F. Final schema reload +-- ============================================================================= + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/13-rental-type-daily-and-email-guard.sql b/supabase/migrations/13-rental-type-daily-and-email-guard.sql new file mode 100644 index 0000000..7fc54e6 --- /dev/null +++ b/supabase/migrations/13-rental-type-daily-and-email-guard.sql @@ -0,0 +1,232 @@ +-- 13-rental-type-daily-and-email-guard.sql +-- Introduce explicit 'single_day' rental_type, normalize legacy values, +-- and harden auto-email guard for individuell rentals. + +-- ============================================================================= +-- A. Normalize and expand rental_type checks +-- ============================================================================= + +alter table public.leads drop constraint if exists leads_rental_type_check; +alter table public.sales_orders drop constraint if exists sales_orders_rental_type_check; + +update public.leads + set rental_type = lower(trim(coalesce(rental_type, ''))); + +update public.sales_orders + set rental_type = lower(trim(coalesce(rental_type, ''))); + +update public.leads + set rental_type = 'individuell' + where rental_type in ('individual', 'custom'); + +update public.sales_orders + set rental_type = 'individuell' + where rental_type in ('individual', 'custom'); + +update public.leads + set rental_type = 'single_day' + where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day'); + +update public.sales_orders + set rental_type = 'single_day' + where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day'); + +-- Existing one-day bookings should be single_day. +update public.leads + set rental_type = 'single_day' + where rental_type = 'weekend' + and total_days = 1; + +update public.sales_orders + set rental_type = 'single_day' + where rental_type = 'weekend' + and total_days = 1; + +-- Two-day non-Saturday starts are effectively single_day rentals, not weekend packages. +update public.leads + set rental_type = 'single_day' + where rental_type = 'weekend' + and total_days = 2 + and date_from is not null + and extract(isodow from date_from) <> 6; + +update public.sales_orders + set rental_type = 'single_day' + where rental_type = 'weekend' + and total_days = 2 + and date_from is not null + and extract(isodow from date_from) <> 6; + +-- Fallback for any unexpected value. +update public.leads + set rental_type = 'weekend' + where rental_type not in ('single_day', 'weekend', 'individuell'); + +update public.sales_orders + set rental_type = 'weekend' + where rental_type not in ('single_day', 'weekend', 'individuell'); + +alter table public.leads + alter column rental_type set default 'weekend'; + +alter table public.sales_orders + alter column rental_type set default 'weekend'; + +alter table public.leads + add constraint leads_rental_type_check + check (rental_type in ('single_day', 'weekend', 'individuell')); + +alter table public.sales_orders + add constraint sales_orders_rental_type_check + check (rental_type in ('single_day', 'weekend', 'individuell')); + +-- ============================================================================= +-- B. Harden notify_lead_qualified() against malformed rental_type values +-- ============================================================================= + +create or replace function public.notify_lead_qualified() +returns trigger +language plpgsql +security definer +as $$ +declare + v_rental_type text := coalesce(lower(trim(NEW.rental_type)), 'weekend'); +begin + -- Never auto-email individuell orders (including legacy synonyms). + if v_rental_type in ('individuell', 'individual', 'custom') then + return NEW; + end if; + + perform pg_notify('lead_qualified', json_build_object( + 'sales_order_id', NEW.id, + 'customer_id', NEW.customer_id, + 'lead_id', NEW.lead_id, + 'order_number', NEW.order_number, + 'total_eur', NEW.total_eur, + 'deposit_eur', NEW.deposit_eur, + 'date_from', NEW.date_from, + 'date_to', NEW.date_to, + 'vehicle_label', NEW.vehicle_label, + 'rental_type', v_rental_type, + 'email_sent', NEW.email_sent + )::text); + + return NEW; +end; +$$; + +-- ============================================================================= +-- C. Update create_lead() classification logic to include daily +-- ============================================================================= + +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_ip_address text default '', + p_ip_country text default '' +) +returns uuid +language plpgsql +security definer +as $$ +declare + v_lead_id uuid; + v_vehicle record; + v_total_days integer := 0; + v_weekend_days integer := 0; + v_weekdays integer := 0; + v_daily_subtotal integer := 0; + v_weekend_subtotal integer := 0; + v_subtotal_eur integer := 0; + v_vat_eur integer := 0; + v_total_eur integer := 0; + v_deposit_eur integer := 0; + v_rental_type text := 'weekend'; + v_cur date; + v_dow integer; +begin + if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then + select daily_price_eur, weekend_price_eur, kaution_eur + into v_vehicle + from public.vehicles + where id = p_vehicle_id; + + if found then + v_total_days := (p_date_to - p_date_from); + + -- Classification: + -- 1 day => single_day + -- 2 days starting Saturday => weekend package + -- 2 days otherwise => single_day + -- > 2 days => individuell (manual processing) + if v_total_days > 2 then + v_rental_type := 'individuell'; + elsif v_total_days = 1 then + v_rental_type := 'single_day'; + elsif v_total_days = 2 and extract(isodow from p_date_from) = 6 then + v_rental_type := 'weekend'; + elsif v_total_days = 2 then + v_rental_type := 'single_day'; + else + v_rental_type := 'weekend'; + end if; + + if v_rental_type = 'individuell' then + v_daily_subtotal := 0; + v_weekend_subtotal := 0; + v_subtotal_eur := 0; + v_vat_eur := 0; + v_total_eur := 0; + v_deposit_eur := 0; + else + v_cur := p_date_from; + while v_cur < p_date_to loop + v_dow := extract(isodow from v_cur); + if v_dow in (6, 7) then + v_weekend_days := v_weekend_days + 1; + end if; + v_cur := v_cur + 1; + end loop; + + v_weekdays := v_total_days - v_weekend_days; + v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur; + v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end); + v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal; + v_vat_eur := round(v_subtotal_eur * 0.20); + v_total_eur := v_subtotal_eur + v_vat_eur; + v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000); + end if; + end if; + end if; + + 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, ip_address, ip_country, + rental_type + ) values ( + p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to, + p_message, p_source, + v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur, + v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country, + v_rental_type + ) + returning id into v_lead_id; + + return v_lead_id; +end; +$$; + +grant execute on function public.create_lead( + text, text, text, uuid, text, date, date, text, text, text, text +) to anon, authenticated, service_role; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/14-email-requested-trigger.sql b/supabase/migrations/14-email-requested-trigger.sql new file mode 100644 index 0000000..aa91852 --- /dev/null +++ b/supabase/migrations/14-email-requested-trigger.sql @@ -0,0 +1,29 @@ +-- 14-sales-order-set-deposit.sql +-- Adds sales_order_set_deposit RPC for updating deposit from admin pricing tab. + +-- ============================================================================= +-- A. RPC: sales_order_set_deposit +-- ============================================================================= + +create or replace function public.sales_order_set_deposit(p_so_id uuid, p_deposit_eur integer) +returns void +language plpgsql +security invoker +as $$ +begin + update public.sales_orders + set deposit_eur = p_deposit_eur, updated_at = now() + where id = p_so_id; + if not found then + raise exception 'sales order % not found', p_so_id; + end if; +end; +$$; + +grant execute on function public.sales_order_set_deposit(uuid, integer) to authenticated; + +-- ============================================================================= +-- B. Schema reload +-- ============================================================================= + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/15-individuell-vat-subtotal-fix.sql b/supabase/migrations/15-individuell-vat-subtotal-fix.sql new file mode 100644 index 0000000..147fd8c --- /dev/null +++ b/supabase/migrations/15-individuell-vat-subtotal-fix.sql @@ -0,0 +1,61 @@ +-- Ensure individuell orders persist net/vat components when total is manually set +-- and backfill existing records where these fields are still zero. + +create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer) +returns void +language plpgsql +security invoker +as $$ +declare + v_so public.sales_orders; + v_subtotal_eur integer := 0; + v_vat_eur integer := 0; +begin + select * into v_so from public.sales_orders where id = p_so_id for update; + if not found then + raise exception 'sales order % not found', p_so_id; + end if; + + if v_so.rental_type != 'individuell' then + raise exception 'can only set total for individuell orders'; + end if; + + if coalesce(p_total_eur, 0) < 0 then + raise exception 'total must be >= 0'; + end if; + + if p_total_eur > 0 then + v_subtotal_eur := round(p_total_eur / 1.2); + v_vat_eur := p_total_eur - v_subtotal_eur; + end if; + + update public.sales_orders + set total_eur = p_total_eur, + subtotal_eur = v_subtotal_eur, + vat_eur = v_vat_eur, + daily_subtotal = v_subtotal_eur, + weekend_subtotal = 0, + weekday_count = coalesce(total_days, 0), + weekend_day_count = 0, + updated_at = now() + where id = p_so_id; +end; +$$; + +grant execute on function public.sales_order_set_total(uuid, integer) to authenticated; + +-- Backfill already existing individuell orders with missing net/vat split. +update public.sales_orders + set subtotal_eur = round(total_eur / 1.2), + vat_eur = total_eur - round(total_eur / 1.2), + daily_subtotal = round(total_eur / 1.2), + weekend_subtotal = 0, + weekday_count = coalesce(total_days, 0), + weekend_day_count = 0, + updated_at = now() + where rental_type = 'individuell' + and coalesce(total_eur, 0) > 0 + and coalesce(subtotal_eur, 0) = 0 + and coalesce(vat_eur, 0) = 0; + +notify pgrst, 'reload schema'; diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file