feat: Add manual email sending workflow and related database changes #1

Merged
Lago merged 4 commits from dev into main 2026-05-17 22:45:47 +02:00
15 changed files with 706 additions and 163 deletions
Showing only changes of commit f46ba8cadc - Show all commits
+1
View File
@@ -22,3 +22,4 @@ docker-compose.override.yml
frontend/config.js
.playwright-mcp
node_modules/
+3
View File
@@ -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:
+6
View File
@@ -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]
+2 -1
View File
@@ -3,7 +3,8 @@ set -eu
cat > /usr/share/nginx/html/config.js <<EOF
window.MCCARS_CONFIG = {
SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}",
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-http://localhost:55590}"
};
EOF
exec nginx -g "daemon off;"
+1
View File
@@ -281,6 +281,7 @@
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
</div>
<div class="dialog-tabs" id="orderDialogTabs" role="tablist"></div>
<div class="dialog-body" id="orderDialogBody"></div>
<div class="dialog-footer" id="orderDialogFooter"></div>
</dialog>
+256 -134
View File
@@ -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() {
<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="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</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;">
@@ -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) {
<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.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 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(rental.type)}">${esc(rental.label)}</span></span></div>
</div>`;
} 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() {
<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="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</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>
@@ -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) =>
`<button class="order-tab${i === 0 ? " active" : ""}" data-order-tab="${tab}">${orderTabLabels[tab]()}</button>`
).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 = `
<dl class="kv">
<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 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>
<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_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
</div>
<h4 style="margin:1.2rem 0 0.6rem;font-size:0.9rem;color:var(--muted);">${t("adminTabDocuments")}</h4>
${docs.length ? renderDocList(docs) : `<p class="muted" style="text-align:center;padding:1rem 0;">${t("adminNoDocuments")}</p>`}
<div style="margin-top:0.8rem;">
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
<div style="display:flex;justify-content:flex-end;margin-top:0.4rem;">
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
orderDialogBody.innerHTML = `
<dl class="kv">
<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>${lang === "de" ? t("adminRentalType") : t("adminRentalTypeEn")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></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>
<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_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
</div>
</div>`;
<h4 style="margin:1.2rem 0 0.6rem;font-size:0.9rem;color:var(--muted);">${t("adminTabDocuments")}</h4>
${docs.length ? renderDocList(docs) : `<p class="muted" style="text-align:center;padding:1rem 0;">${t("adminNoDocuments")}</p>`}
<div style="margin-top:0.8rem;">
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.4rem;">
<div>${emailSent !== 1 ? `<button class="btn small" type="button" data-manual-email-send data-order-id="${o.id}" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button>` : ''}</div>
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
</div>
</div>`;
// 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 = `
<div class="pricing-card">
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} ×${perDay || "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} ×${perWeekend || "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.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>${isIndividuell && !isEmailLocked
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${total}" style="font-weight:600;width:120px;" />`
: '€ ' + total.toLocaleString("de-DE")
}</span>
</div>
${!isEmailLocked ? `<div class="price-row muted" id="inclVatHint"><span></span><span style="font-size:0.78rem;">${inclVatLabel}</span></div>` : ''}
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span>
<span>${isEmailLocked || !isIndividuell
? '€ ' + deposit.toLocaleString("de-DE")
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" />`
}</span>
</div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${days}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("adminRentalTypeEn")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
</div>
${isIndividuell && !isEmailLocked ? `<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;"><button class="btn small" id="orderPricingSave">${t("adminSave")}</button></div>` : ''}`;
// 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 += `
<div class="pricing-card" style="margin-bottom:0.9rem;">
@@ -1022,7 +1128,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 class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</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>
@@ -1180,6 +1286,22 @@ function attachRealtime() {
// =========================================================================
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[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);
+6 -2
View File
@@ -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",
+14 -4
View File
@@ -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;
+9 -4
View File
@@ -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"
@@ -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]
}
],
File diff suppressed because one or more lines are too long
@@ -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';
@@ -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';
@@ -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';
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}