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:
+119
-13
@@ -262,9 +262,9 @@ function loadForEdit(id) {
|
||||
vehicleForm.seats.value = v.seats;
|
||||
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
||||
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
||||
vehicleForm.max_daily_km.value = v.max_daily_km || 150;
|
||||
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
|
||||
vehicleForm.max_km_weekend.value = v.max_km_weekend || '';
|
||||
vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
|
||||
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
|
||||
vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
|
||||
vehicleForm.sort_order.value = v.sort_order;
|
||||
vehicleForm.location.value = v.location;
|
||||
vehicleForm.description_de.value = v.description_de;
|
||||
@@ -283,10 +283,10 @@ resetBtn.addEventListener("click", () => {
|
||||
vehicleForm.sort_order.value = 100;
|
||||
vehicleForm.location.value = "Steiermark (TBD)";
|
||||
vehicleForm.seats.value = 2;
|
||||
vehicleForm.max_daily_km.value = 150;
|
||||
vehicleForm.included_km_per_day.value = 150;
|
||||
vehicleForm.weekend_price_eur.value = 0;
|
||||
vehicleForm.kaution_eur.value = 5000;
|
||||
vehicleForm.max_km_weekend.value = '';
|
||||
vehicleForm.price_per_km_eur.value = 1.50;
|
||||
state.currentPhotoPath = null;
|
||||
updatePreview("");
|
||||
formTitle.textContent = "Neues Fahrzeug";
|
||||
@@ -309,9 +309,9 @@ vehicleForm.addEventListener("submit", async (e) => {
|
||||
seats: +fd.get("seats") || 2,
|
||||
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
||||
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
|
||||
max_daily_km: +fd.get("max_daily_km") || 150,
|
||||
kaution_eur: +fd.get("kaution_eur") || 5000,
|
||||
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null,
|
||||
included_km_per_day: +fd.get("included_km_per_day") || 150,
|
||||
kaution_eur: +fd.get("kaution_eur") || 5000,
|
||||
price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
|
||||
sort_order: +fd.get("sort_order") || 100,
|
||||
location: fd.get("location") || "Steiermark (TBD)",
|
||||
description_de: fd.get("description_de") || "",
|
||||
@@ -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>${esc(l.vehicle_label || "—")}</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><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
||||
<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 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"><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("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</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("adminRentalType") : t("adminRentalTypeEn")}</span><span><span class="pill pill-${esc(l.rental_type || 'weekend')}">${esc(l.rental_type || 'weekend')}</span></span></div>
|
||||
</div>`;
|
||||
} else if (tab === "documents") {
|
||||
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>${esc(o.vehicle_label || "—")}</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><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_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>`;
|
||||
ordersTableBody.appendChild(tr);
|
||||
}
|
||||
@@ -719,6 +723,10 @@ async function openOrder(id) {
|
||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||
const total = o.total_eur || 0;
|
||||
const deposit = o.deposit_eur || 0;
|
||||
const emailSent = o.email_sent || 0;
|
||||
const emailSentText = emailSent === 1 ? '✓' : emailSent === 2 ? '✗' : '—';
|
||||
const emailSentPillClass = emailSent === 1 ? 'active' : emailSent === 2 ? 'disqualified' : 'new';
|
||||
const isEmailLocked = emailSent === 1;
|
||||
|
||||
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
|
||||
|
||||
@@ -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" ? "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>${t("adminTotalLabel")}</dt><dd style="font-weight:600;">€ ${total.toLocaleString("de-DE")}</dd>
|
||||
<dt>${t("adminDepositLabel")}</dt><dd>€ ${deposit.toLocaleString("de-DE")}</dd>
|
||||
<dt>${t("adminTotalLabel")}</dt><dd id="orderTotalDisplay" style="font-weight:600;">
|
||||
${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>
|
||||
<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>
|
||||
@@ -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
|
||||
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 });
|
||||
@@ -941,6 +1022,7 @@ async function renderCustomerTab(tab, c) {
|
||||
</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" ? "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;">
|
||||
<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>
|
||||
@@ -974,8 +1056,32 @@ async function renderCustomerTab(tab, c) {
|
||||
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
|
||||
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
||||
if (ok) {
|
||||
btn.classList.remove("ghost");
|
||||
btn.style.backgroundColor = "";
|
||||
btn.style.color = "";
|
||||
btn.textContent = "✓";
|
||||
setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500);
|
||||
setTimeout(() => {
|
||||
btn.textContent = t("adminSaveNotes");
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => {
|
||||
const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`);
|
||||
const originalValue = noteEl.value;
|
||||
noteEl.addEventListener("input", () => {
|
||||
const isDirty = noteEl.value !== originalValue;
|
||||
if (isDirty) {
|
||||
btn.classList.remove("ghost");
|
||||
btn.style.backgroundColor = "var(--accent-strong)";
|
||||
btn.style.color = "#fff";
|
||||
btn.textContent = "Speichern (unsaved)";
|
||||
} else {
|
||||
btn.classList.add("ghost");
|
||||
btn.style.backgroundColor = "";
|
||||
btn.style.color = "";
|
||||
btn.textContent = t("adminSaveNotes");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user