From f46ba8cadcbf344cf395f35adb0d34f805bbd79d Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 17 May 2026 22:35:11 +0200 Subject: [PATCH] feat(i18n): add VAT labels and email sent messages in German and English style(admin): increase max-width of admin page and adjust table styles fix(n8n): enhance workflow import and publishing process fix(workflows): update SQL queries for fetching and updating sales orders feat(migrations): normalize rental types and enhance email guard for individuell rentals feat(migrations): add RPC for updating deposit in sales orders fix(migrations): ensure individuell orders persist net/vat components and backfill existing records test: update last run status to failed --- .gitignore | 1 + docker-compose.local.yml | 3 + docker-compose.yml | 6 + frontend/99-config.sh | 3 +- frontend/admin.html | 1 + frontend/admin.js | 390 ++++++++++++------ frontend/i18n.js | 8 +- frontend/styles.css | 18 +- n8n/bootstrap/bootstrap-n8n.sh | 13 +- .../01-qualification-payment-email.json | 24 +- n8n/workflows/03-manual-email-send.json | 76 +++- .../13-rental-type-daily-and-email-guard.sql | 232 +++++++++++ .../migrations/14-email-requested-trigger.sql | 29 ++ .../15-individuell-vat-subtotal-fix.sql | 61 +++ test-results/.last-run.json | 4 + 15 files changed, 706 insertions(+), 163 deletions(-) create mode 100644 supabase/migrations/13-rental-type-daily-and-email-guard.sql create mode 100644 supabase/migrations/14-email-requested-trigger.sql create mode 100644 supabase/migrations/15-individuell-vat-subtotal-fix.sql create mode 100644 test-results/.last-run.json 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 5dc3ee8..f07ec0d 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -27,6 +27,9 @@ services: - ./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 db0ccad..1014b30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -220,6 +220,9 @@ services: - /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: - | @@ -248,6 +251,9 @@ services: 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 <Bestellung +
diff --git a/frontend/admin.js b/frontend/admin.js index 5039ef1..e4d3644 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -70,6 +70,14 @@ const state = { forcedRotation: false, }; +function notify(message, duration = 3000) { + if (typeof window.showToast === "function") { + window.showToast(message, duration); + return; + } + alert(message); +} + // ========================================================================= // AUTH FLOW // ========================================================================= @@ -396,6 +404,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,7 +413,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(l.rental_type || 'weekend')} + ${esc(rental.label)} ${totalStr} ${esc(l.status)} @@ -553,6 +562,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; @@ -569,8 +579,8 @@ async function renderLeadTab(tab, l) {
${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.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("adminRentalTypeEn")}${esc(l.rental_type || 'weekend')}
+
${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}${l.total_days || 0}
+
${lang === "de" ? t("adminRentalType") : t("adminRentalTypeEn")}${esc(rental.label)}
`; } else if (tab === "documents") { const docs = await loadLeadAttachments(l.id); @@ -687,6 +697,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); @@ -696,7 +707,7 @@ 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(o.rental_type || 'weekend')} + ${esc(rental.label)} ${totalStr} ${o.kaution_paid ? "✓" : "—"} ${o.rental_paid ? "✓" : "—"} @@ -712,13 +723,88 @@ 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; @@ -728,149 +814,168 @@ async function openOrder(id) { 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")}
- ${o.rental_type === 'individuell' && !isEmailLocked - ? ` - ` - : '€ ' + total.toLocaleString("de-DE") - } -
-
${t("adminDepositLabel")}
${isEmailLocked - ? '€ ' + deposit.toLocaleString("de-DE") - : `` - }
-
${t("adminEmailSent")}
${emailSentText}
- ${o.rental_type === 'individuell' && !isEmailLocked ? `
` : ''} -
-
- - - -
-

${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("adminRentalTypeEn")}
${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); + }); }); - }); - // 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"); + // 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("adminRentalTypeEn")}${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); + }); } - // 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); - } - }); - - document.querySelector("#orderTotalSave")?.addEventListener("click", async () => { - const input = document.querySelector("#orderTotalInput"); - if (!input) return; - const { error } = await supabase.rpc("sales_order_set_total", { - p_so_id: o.id, - p_total_eur: +input.value, - }); - if (error) { - alert(error.message); - return; - } - await loadSalesOrders(); - await openOrder(id); // re-render - }); - - document.querySelector("#manualEmailSend")?.addEventListener("click", async () => { - const btn = document.querySelector("#manualEmailSend"); - btn.disabled = true; - - showToast(t("emailSentToast"), 3000); - - const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "http://localhost:55590/webhook/manual-email-send"; - try { - await fetch(n8nUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sales_order_id: o.id }) - }); - } catch (webhookError) { - console.error("Webhook error:", webhookError); - } - - setTimeout(async () => { - await loadSalesOrders(); - await openOrder(id); - }, 3000); - }); - orderDialogFooter.innerHTML = ""; - orderDialog.showModal(); - orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true }); } async function toggleSalesOrderState(orderId, action) { @@ -1013,6 +1118,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 += `
@@ -1022,7 +1128,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(o.rental_type || 'weekend')}
+
${lang === "de" ? "Miettyp" : "Rental type"}${esc(rental.label)}
@@ -1180,6 +1286,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/i18n.js b/frontend/i18n.js index 07f0111..c4debf7 100644 --- a/frontend/i18n.js +++ b/frontend/i18n.js @@ -223,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", @@ -240,7 +242,7 @@ export const translations = { rentalTypeWeekend: "Wochenende", rentalTypeIndividuell: "Individuell", adminSortOrder: "Ordnung", - adminEmailSent: "Email", + adminEmailSent: "E-Mail gesendet", sendEmailButton: "E-Mail senden", emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...", emailAlreadySent: "Bereits gesendet", @@ -470,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", @@ -487,7 +491,7 @@ export const translations = { rentalTypeWeekend: "Weekend", rentalTypeIndividuell: "Custom", adminSortOrder: "Order", - adminEmailSent: "Email", + adminEmailSent: "Email sent", sendEmailButton: "Send Email", emailSentToast: "Email is being prepared and will be sent shortly...", emailAlreadySent: "Already sent", 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 3cbe234..2313b45 100644 --- a/n8n/bootstrap/bootstrap-n8n.sh +++ b/n8n/bootstrap/bootstrap-n8n.sh @@ -76,12 +76,9 @@ 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 \ @@ -95,4 +92,12 @@ if [ -f "$WORKFLOW03_TEMPLATE" ]; then 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 e14a5e2..b90b57d 100644 --- a/n8n/workflows/01-qualification-payment-email.json +++ b/n8n/workflows/01-qualification-payment-email.json @@ -32,7 +32,7 @@ { "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", @@ -84,7 +84,7 @@ { "parameters": { "operation": "executeQuery", - "query": "UPDATE public.sales_orders SET email_sent = 1, updated_at = now() WHERE id = '{{ $json.payload.id }}'::uuid", + "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", @@ -102,7 +102,7 @@ { "parameters": { "operation": "executeQuery", - "query": "UPDATE public.sales_orders SET email_sent = 2, updated_at = now() WHERE id = '{{ $json.payload.id }}'::uuid", + "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", @@ -121,20 +121,28 @@ "parameters": { "conditions": { "options": { - "testDirectly": true + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" }, - "boolean": [ + "conditions": [ { + "id": "cond-email-error", "leftValue": "={{ $json.error }}", - "condition": "isEmpty" + "rightValue": "", + "operator": { + "type": "string", + "operation": "notExists" + } } - ] + ], + "combinator": "and" } }, "id": "check-email-error", "name": "IF", "type": "n8n-nodes-base.if", - "typeVersion": 1, + "typeVersion": 2, "position": [896, 0] } ], diff --git a/n8n/workflows/03-manual-email-send.json b/n8n/workflows/03-manual-email-send.json index f94538f..89b24ee 100644 --- a/n8n/workflows/03-manual-email-send.json +++ b/n8n/workflows/03-manual-email-send.json @@ -18,7 +18,7 @@ { "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.sales_order_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\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", @@ -36,7 +36,7 @@ { "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 sales_order_id: item.id,\n};\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 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", @@ -70,7 +70,7 @@ { "parameters": { "operation": "executeQuery", - "query": "UPDATE public.sales_orders SET email_sent = 1, updated_at = now() WHERE id = '{{ $json.sales_order_id }}'::uuid", + "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", @@ -88,7 +88,7 @@ { "parameters": { "operation": "executeQuery", - "query": "UPDATE public.sales_orders SET email_sent = 2, updated_at = now() WHERE id = '{{ $json.sales_order_id }}'::uuid", + "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", @@ -107,21 +107,55 @@ "parameters": { "conditions": { "options": { - "testDirectly": true + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" }, - "boolean": [ + "conditions": [ { + "id": "cond-error", "leftValue": "={{ $json.error }}", - "condition": "isEmpty" + "rightValue": "", + "operator": { + "type": "string", + "operation": "notExists" + } } - ] + ], + "combinator": "and" } }, "id": "check-error", "name": "IF", "type": "n8n-nodes-base.if", - "typeVersion": 1, + "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": {}, @@ -187,13 +221,35 @@ } ] ] + }, + "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-cdef12345678", + "versionId": "d3c4e5f6-1234-5678-90ab-cdef12345680", "meta": { "templateCredsSetupCompleted": true }, 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