feat: Add manual email sending workflow and related database changes

- Implemented a new n8n workflow for manual email sending, including webhook trigger, order data fetching, email building, and sending.
- Added logic to format email content with customer and order details.
- Introduced new columns in the sales_orders table to track email sending status.
- Updated database functions to handle new rental types and email status.
- Created new RPCs for updating email status and retrieving email details for sales orders.
This commit is contained in:
2026-05-17 18:04:36 +02:00
parent e24bc743e2
commit e34d56e36a
13 changed files with 1127 additions and 106 deletions
+2
View File
@@ -25,6 +25,8 @@ services:
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro - ./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/09-site-settings.sql:/sql/09-site-settings.sql:ro
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.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
kong: kong:
volumes: volumes:
+4
View File
@@ -218,6 +218,8 @@ 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/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/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/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
entrypoint: ["sh","-c"] entrypoint: ["sh","-c"]
command: command:
- | - |
@@ -244,6 +246,8 @@ 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/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/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/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
echo "post-init done." echo "post-init done."
restart: "no" restart: "no"
networks: [mccars] networks: [mccars]
+7 -4
View File
@@ -101,6 +101,7 @@
<th data-i18n="adminNameEmail">Name / E-Mail</th> <th data-i18n="adminNameEmail">Name / E-Mail</th>
<th data-i18n="adminVehicleTab">Fahrzeug</th> <th data-i18n="adminVehicleTab">Fahrzeug</th>
<th data-i18n="adminPeriod">Zeitraum</th> <th data-i18n="adminPeriod">Zeitraum</th>
<th data-i18n="adminRentalType">Miettyp</th>
<th data-i18n="adminTotalPrice">Gesamtbetrag</th> <th data-i18n="adminTotalPrice">Gesamtbetrag</th>
<th data-i18n="adminStatus">Status</th> <th data-i18n="adminStatus">Status</th>
<th></th> <th></th>
@@ -146,10 +147,12 @@
<th data-i18n="adminNameEmail">Name / E-Mail</th> <th data-i18n="adminNameEmail">Name / E-Mail</th>
<th data-i18n="adminVehicleTab">Fahrzeug</th> <th data-i18n="adminVehicleTab">Fahrzeug</th>
<th data-i18n="adminPeriod">Zeitraum</th> <th data-i18n="adminPeriod">Zeitraum</th>
<th data-i18n="adminRentalType">Miettyp</th>
<th data-i18n="adminTotalPrice">Gesamtbetrag</th> <th data-i18n="adminTotalPrice">Gesamtbetrag</th>
<th>Kaution</th> <th>Kaution</th>
<th>Miete</th> <th>Miete</th>
<th data-i18n="adminStatus">Status</th> <th data-i18n="adminStatus">Status</th>
<th data-i18n="adminEmailSent">Email</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -192,13 +195,13 @@
</div> </div>
<div class="row3"> <div class="row3">
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label> <label><span>Inkl. km/Tag</span><input type="number" name="included_km_per_day" min="0" value="150" /></label>
<label><span data-i18n="adminPricePerKm">Preis extra km (€)</span><input type="number" name="price_per_km_eur" step="0.01" min="0" value="1.50" /></label>
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label> <label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
<label><span data-i18n="adminMaxKmWeekend">Max. km/Wochenendtag</span><input type="number" name="max_km_weekend" min="0" placeholder="wie km/Tag" /></label>
</div> </div>
<div class="row2"> <div class="row2">
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label> <label><span data-i18n="adminSortOrder">Ordnung</span><input type="number" name="sort_order" value="100" /></label>
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label> <label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
</div> </div>
@@ -304,6 +307,6 @@
<div class="dialog-footer" id="customerDialogFooter"></div> <div class="dialog-footer" id="customerDialogFooter"></div>
</dialog> </dialog>
<script type="module" src="admin.js"></script> <script type="module" src="admin.js?v=3"></script>
</body> </body>
</html> </html>
+116 -10
View File
@@ -262,9 +262,9 @@ function loadForEdit(id) {
vehicleForm.seats.value = v.seats; vehicleForm.seats.value = v.seats;
vehicleForm.daily_price_eur.value = v.daily_price_eur; vehicleForm.daily_price_eur.value = v.daily_price_eur;
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0; vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
vehicleForm.max_daily_km.value = v.max_daily_km || 150; vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
vehicleForm.kaution_eur.value = v.kaution_eur || 5000; vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
vehicleForm.max_km_weekend.value = v.max_km_weekend || ''; vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
vehicleForm.sort_order.value = v.sort_order; vehicleForm.sort_order.value = v.sort_order;
vehicleForm.location.value = v.location; vehicleForm.location.value = v.location;
vehicleForm.description_de.value = v.description_de; vehicleForm.description_de.value = v.description_de;
@@ -283,10 +283,10 @@ resetBtn.addEventListener("click", () => {
vehicleForm.sort_order.value = 100; vehicleForm.sort_order.value = 100;
vehicleForm.location.value = "Steiermark (TBD)"; vehicleForm.location.value = "Steiermark (TBD)";
vehicleForm.seats.value = 2; vehicleForm.seats.value = 2;
vehicleForm.max_daily_km.value = 150; vehicleForm.included_km_per_day.value = 150;
vehicleForm.weekend_price_eur.value = 0; vehicleForm.weekend_price_eur.value = 0;
vehicleForm.kaution_eur.value = 5000; vehicleForm.kaution_eur.value = 5000;
vehicleForm.max_km_weekend.value = ''; vehicleForm.price_per_km_eur.value = 1.50;
state.currentPhotoPath = null; state.currentPhotoPath = null;
updatePreview(""); updatePreview("");
formTitle.textContent = "Neues Fahrzeug"; formTitle.textContent = "Neues Fahrzeug";
@@ -309,9 +309,9 @@ vehicleForm.addEventListener("submit", async (e) => {
seats: +fd.get("seats") || 2, seats: +fd.get("seats") || 2,
daily_price_eur: +fd.get("daily_price_eur") || 0, daily_price_eur: +fd.get("daily_price_eur") || 0,
weekend_price_eur: +fd.get("weekend_price_eur") || 0, weekend_price_eur: +fd.get("weekend_price_eur") || 0,
max_daily_km: +fd.get("max_daily_km") || 150, included_km_per_day: +fd.get("included_km_per_day") || 150,
kaution_eur: +fd.get("kaution_eur") || 5000, kaution_eur: +fd.get("kaution_eur") || 5000,
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null, price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
sort_order: +fd.get("sort_order") || 100, sort_order: +fd.get("sort_order") || 100,
location: fd.get("location") || "Steiermark (TBD)", location: fd.get("location") || "Steiermark (TBD)",
description_de: fd.get("description_de") || "", description_de: fd.get("description_de") || "",
@@ -404,6 +404,7 @@ function renderLeads() {
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td> <td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
<td>${esc(l.vehicle_label || "—")}</td> <td>${esc(l.vehicle_label || "—")}</td>
<td>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</td> <td>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</td>
<td style="white-space:nowrap;"><span class="pill pill-${esc(l.rental_type || 'weekend')}">${esc(l.rental_type || 'weekend')}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td> <td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td> <td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
<td style="white-space:nowrap;"> <td style="white-space:nowrap;">
@@ -567,8 +568,9 @@ async function renderLeadTab(tab, l) {
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div> <div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div> <div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div> <div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${((l.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150))} km</span></div> <div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div> <div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("adminRentalTypeEn")}</span><span><span class="pill pill-${esc(l.rental_type || 'weekend')}">${esc(l.rental_type || 'weekend')}</span></span></div>
</div>`; </div>`;
} else if (tab === "documents") { } else if (tab === "documents") {
const docs = await loadLeadAttachments(l.id); const docs = await loadLeadAttachments(l.id);
@@ -694,10 +696,12 @@ function renderOrders() {
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td> <td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
<td>${esc(o.vehicle_label || "—")}</td> <td>${esc(o.vehicle_label || "—")}</td>
<td>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</td> <td>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</td>
<td style="white-space:nowrap;"><span class="pill pill-${esc(o.rental_type || 'weekend')}">${esc(o.rental_type || 'weekend')}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td> <td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td> <td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td> <td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td> <td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
<td style="white-space:nowrap;"><span class="pill pill-${o.email_sent === 1 ? 'active' : o.email_sent === 2 ? 'disqualified' : 'new'}">${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'}</span></td>
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`; <td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
ordersTableBody.appendChild(tr); ordersTableBody.appendChild(tr);
} }
@@ -719,6 +723,10 @@ async function openOrder(id) {
const cust = state.customers.find(c => c.id === o.customer_id); const cust = state.customers.find(c => c.id === o.customer_id);
const total = o.total_eur || 0; const total = o.total_eur || 0;
const deposit = o.deposit_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 || "—"}`; orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
@@ -735,8 +743,19 @@ async function openOrder(id) {
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd> <dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd> <dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</dd> <dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</dd>
<dt>${t("adminTotalLabel")}</dt><dd style="font-weight:600;">${total.toLocaleString("de-DE")}</dd> <dt>${t("adminTotalLabel")}</dt><dd id="orderTotalDisplay" style="font-weight:600;">
<dt>${t("adminDepositLabel")}</dt><dd>€ ${deposit.toLocaleString("de-DE")}</dd> ${o.rental_type === 'individuell' && !isEmailLocked
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${o.total_eur || 0}" style="font-weight:600;width:120px;" ${isEmailLocked ? 'disabled' : ''} />
<button class="btn small" id="orderTotalSave" style="margin-left:0.4rem;" ${isEmailLocked ? 'disabled' : ''}>${t("adminSave")}</button>`
: '€ ' + total.toLocaleString("de-DE")
}
</dd>
<dt>${t("adminDepositLabel")}</dt><dd>${isEmailLocked
? '€ ' + deposit.toLocaleString("de-DE")
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" ${isEmailLocked ? 'disabled' : ''} /><button class="btn small" id="orderDepositSave" style="margin-left:0.4rem;" ${isEmailLocked ? 'disabled' : ''}>${t("adminSave")}</button>`
}</dd>
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
${o.rental_type === 'individuell' && !isEmailLocked ? `<dt></dt><dd><button class="btn small" id="manualEmailSend" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button></dd>` : ''}
</dl> </dl>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;"> <div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button> <button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
@@ -778,15 +797,77 @@ async function openOrder(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");
}
});
}
// Save notes // Save notes
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => { document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value); const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
if (ok) { if (ok) {
noteIsDirty = false;
document.querySelector("#orderNoteSave").textContent = "✓"; document.querySelector("#orderNoteSave").textContent = "✓";
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500); 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 = ""; orderDialogFooter.innerHTML = "";
orderDialog.showModal(); orderDialog.showModal();
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true }); orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
@@ -941,6 +1022,7 @@ async function renderCustomerTab(tab, c) {
</div> </div>
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div> <div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</span></div> <div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(o.rental_type || 'weekend')}">${esc(o.rental_type || 'weekend')}</span></span></div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;"> <div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button> <button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button> <button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
@@ -974,8 +1056,32 @@ async function renderCustomerTab(tab, c) {
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`); const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || ""); const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
if (ok) { if (ok) {
btn.classList.remove("ghost");
btn.style.backgroundColor = "";
btn.style.color = "";
btn.textContent = "✓"; 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");
} }
}); });
}); });
+23 -7
View File
@@ -1,5 +1,5 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; 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_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || ""; const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
@@ -215,7 +215,7 @@ function openDetails(id) {
<div class="spec-row" style="margin:1rem 0;"> <div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div> <div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div> <div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div> <div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
</div> </div>
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;"> <div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div> <div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
@@ -398,11 +398,25 @@ async function updateSidebar() {
const vat = price.vat_eur; const vat = price.vat_eur;
const total = price.total_eur; const total = price.total_eur;
const deposit = price.deposit_eur; const deposit = price.deposit_eur;
const kmPerWeekday = price.max_daily_km; const includedKmPerDay = price.included_km_per_day || 150;
const kmPerWeekendDay = price.max_km_weekend; const includedKm = totalDays * includedKmPerDay;
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url); const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
if (totalDays > 2) {
// Individuell mode: show info banner instead of pricing
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-info-banner">
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
<p>${t("bpfIndividuellDesc")}</p>
</div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
} else {
bpfSidebarPlaceholder.style.display = "none"; bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block"; bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = ` bpfSidebarContent.innerHTML = `
@@ -415,12 +429,14 @@ async function updateSidebar() {
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div> <div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div> <div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div> <div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div> <div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div> <div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p> <p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p> <p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`; `;
} }
}
bpfCar.addEventListener("change", updateSidebar); bpfCar.addEventListener("change", updateSidebar);
bpfFrom.addEventListener("change", updateSidebar); bpfFrom.addEventListener("change", updateSidebar);
+26 -2
View File
@@ -89,6 +89,7 @@ export const translations = {
bpfWeekendRate: "Wochenendmiete", bpfWeekendRate: "Wochenendmiete",
bpfWeekendDef: "Wochenende: Samstag 9:00 Sonntag 20:00", bpfWeekendDef: "Wochenende: Samstag 9:00 Sonntag 20:00",
bpfMaxKm: "Max. km/Tag", bpfMaxKm: "Max. km/Tag",
bpfInclKmPerDay: "Inkl. km/Tag",
bpfExtraKm: "Extra km", bpfExtraKm: "Extra km",
bpfPriceOverview: "Preisübersicht", bpfPriceOverview: "Preisübersicht",
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht", bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
@@ -176,7 +177,7 @@ export const translations = {
adminVehicleTab: "Fahrzeug", adminVehicleTab: "Fahrzeug",
adminPeriod: "Zeitraum", adminPeriod: "Zeitraum",
adminKaution: "Kaution (€)", adminKaution: "Kaution (€)",
adminMaxKmWeekend: "Max. km/Wochenendtag", adminMaxKmWeekend: "Inkl. km/Wochenende",
adminTotalPrice: "Gesamtbetrag", adminTotalPrice: "Gesamtbetrag",
adminLifetimeValueCol: "Gesamtwert", adminLifetimeValueCol: "Gesamtwert",
adminTabGeneral: "Allgemein", adminTabGeneral: "Allgemein",
@@ -234,6 +235,17 @@ export const translations = {
adminNoteEn: "Note", adminNoteEn: "Note",
adminSave: "Speichern", adminSave: "Speichern",
adminSaveEn: "Save", adminSaveEn: "Save",
adminPricePerKm: "Preis extra km (€)",
adminRentalType: "Miettyp",
rentalTypeWeekend: "Wochenende",
rentalTypeIndividuell: "Individuell",
adminSortOrder: "Ordnung",
adminEmailSent: "Email",
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: { en: {
navCars: "Fleet", navCars: "Fleet",
@@ -324,6 +336,7 @@ export const translations = {
bpfWeekendRate: "Weekend rate", bpfWeekendRate: "Weekend rate",
bpfWeekendDef: "Weekend: Saturday 9 AM Sunday 8 PM", bpfWeekendDef: "Weekend: Saturday 9 AM Sunday 8 PM",
bpfMaxKm: "Max. km/day", bpfMaxKm: "Max. km/day",
bpfInclKmPerDay: "Included km/day",
bpfExtraKm: "Extra km", bpfExtraKm: "Extra km",
bpfPriceOverview: "Price overview", bpfPriceOverview: "Price overview",
bpfSelectForPrice: "Select vehicle and date for a price overview", bpfSelectForPrice: "Select vehicle and date for a price overview",
@@ -411,7 +424,7 @@ export const translations = {
adminVehicleTab: "Vehicle", adminVehicleTab: "Vehicle",
adminPeriod: "Period", adminPeriod: "Period",
adminKaution: "Deposit (€)", adminKaution: "Deposit (€)",
adminMaxKmWeekend: "Max. km/weekend day", adminMaxKmWeekend: "Included km/weekend",
adminTotalPrice: "Total", adminTotalPrice: "Total",
adminLifetimeValueCol: "Lifetime", adminLifetimeValueCol: "Lifetime",
adminTabGeneral: "General", adminTabGeneral: "General",
@@ -469,6 +482,17 @@ export const translations = {
adminNoteEn: "Notiz", adminNoteEn: "Notiz",
adminSave: "Save", adminSave: "Save",
adminSaveEn: "Speichern", adminSaveEn: "Speichern",
adminPricePerKm: "Extra km price (€)",
adminRentalType: "Rental type",
rentalTypeWeekend: "Weekend",
rentalTypeIndividuell: "Custom",
adminSortOrder: "Order",
adminEmailSent: "Email",
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.",
}, },
}; };
+1 -34
View File
@@ -113,7 +113,6 @@
<nav class="main-nav" aria-label="Hauptnavigation"> <nav class="main-nav" aria-label="Hauptnavigation">
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a> <a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#stimmen" data-i18n="navReviews">Stimmen</a> <a href="#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="#buchen" data-i18n="navBook">Buchen</a> <a href="#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a> <a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -179,37 +178,6 @@
</div> </div>
</section> </section>
<!-- Why -->
<section id="warum" style="background:var(--bg-elev);">
<a href="/impressum" data-i18n="imprint">Impressum</a>
<a href="/agb" data-i18n="terms">AGB</a>
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspaß.</h2>
</div>
</div>
<div class="why-grid">
<article class="why-card">
<div class="icon">🛡</div>
<h3 data-i18n="whyInsurance">Versicherungsschutz</h3>
<p data-i18n="whyInsuranceText">Vollkasko mit klarem Selbstbehalt.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyFleet">Premium Flotte</h3>
<p data-i18n="whyFleetText">Handverlesene Performance-Modelle.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyDeposit">Faire Kaution</h3>
<p data-i18n="whyDepositText">Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.</p>
</article>
</div>
</div>
</section>
<!-- Toast Notification --> <!-- Toast Notification -->
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div> <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Reviews --> <!-- Reviews -->
@@ -409,7 +377,6 @@
<div> <div>
<h4 data-i18n="footerNav">Navigation</h4> <h4 data-i18n="footerNav">Navigation</h4>
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a> <a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#buchen" data-i18n="navBook">Buchen</a> <a href="#buchen" data-i18n="navBook">Buchen</a>
</div> </div>
@@ -447,6 +414,6 @@
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div> <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script src="config.js"></script> <script src="config.js"></script>
<script type="module" src="app.js"></script> <script type="module" src="app.js?v=3"></script>
</body> </body>
</html> </html>
+2 -3
View File
@@ -20,10 +20,9 @@ server {
add_header Cache-Control "public"; add_header Cache-Control "public";
try_files $uri =404; 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)$ { location ~* \.(?:css|js)$ {
expires 7d; add_header Cache-Control "no-store";
add_header Cache-Control "public, max-age=604800";
try_files $uri =404; try_files $uri =404;
} }
+15
View File
@@ -3,6 +3,8 @@ set -eu
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}" WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.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" CREDENTIALS_FILE="/tmp/mc-cars-credentials.json"
required_var() { required_var() {
@@ -80,4 +82,17 @@ n8n import:workflow --input="$WORKFLOW_RENDERED"
echo "[n8n-bootstrap] Activating workflow ${N8N_PAYMENT_WORKFLOW_ID}" echo "[n8n-bootstrap] Activating workflow ${N8N_PAYMENT_WORKFLOW_ID}"
n8n update:workflow --id="${N8N_PAYMENT_WORKFLOW_ID}" --active=true 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
echo "[n8n-bootstrap] Bootstrap complete" echo "[n8n-bootstrap] Bootstrap complete"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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';
@@ -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';