feat: add deposit and weekend km allowance to vehicles, update migrations and booking flow

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 16:00:06 +02:00
parent d960e37aa8
commit e85b319c93
10 changed files with 83 additions and 39 deletions
+1
View File
@@ -18,6 +18,7 @@ services:
- ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro - ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro
- ./supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro - ./supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro
- ./supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro - ./supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
- ./supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
kong: kong:
volumes: volumes:
+3
View File
@@ -107,6 +107,7 @@ services:
PGRST_DB_ANON_ROLE: anon PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_DB_CHANNEL_ENABLED: "true"
networks: [mccars] networks: [mccars]
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
@@ -210,6 +211,7 @@ services:
- /mnt/user/appdata/mc-cars/supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
entrypoint: ["sh","-c"] entrypoint: ["sh","-c"]
command: command:
- | - |
@@ -229,6 +231,7 @@ services:
-f /sql/post-boot.sql -f /sql/post-boot.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/02-leads.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/02-leads.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/03-booking-flow.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/03-booking-flow.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/04-kaution-weekend-km.sql
echo "post-init done." echo "post-init done."
restart: "no" restart: "no"
networks: [mccars] networks: [mccars]
+6 -4
View File
@@ -139,10 +139,7 @@
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span> <span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" /> <input type="file" id="photoInput" accept="image/*" />
</label> </label>
<label> <input type="hidden" name="photo_url" />
<span data-i18n="adminPhotoUrl">Foto-URL (wird automatisch gesetzt nach Upload)</span>
<input type="url" name="photo_url" placeholder="https://..." />
</label>
<div class="row2"> <div class="row2">
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label> <label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
@@ -163,6 +160,11 @@
<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>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></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 class="row2">
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label> <label><span data-i18n="adminSort">Reihenfolge</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>
+11 -1
View File
@@ -230,6 +230,8 @@ function loadForEdit(id) {
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.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.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;
@@ -250,6 +252,8 @@ resetBtn.addEventListener("click", () => {
vehicleForm.seats.value = 2; vehicleForm.seats.value = 2;
vehicleForm.max_daily_km.value = 150; vehicleForm.max_daily_km.value = 150;
vehicleForm.weekend_price_eur.value = 0; vehicleForm.weekend_price_eur.value = 0;
vehicleForm.kaution_eur.value = 5000;
vehicleForm.max_km_weekend.value = '';
state.currentPhotoPath = null; state.currentPhotoPath = null;
updatePreview(""); updatePreview("");
formTitle.textContent = "Neues Fahrzeug"; formTitle.textContent = "Neues Fahrzeug";
@@ -273,6 +277,8 @@ vehicleForm.addEventListener("submit", async (e) => {
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, 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,
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") || "",
@@ -316,11 +322,15 @@ photoInput.addEventListener("change", async () => {
formFeedback.className = "form-feedback"; formFeedback.className = "form-feedback";
formFeedback.textContent = "Uploading photo..."; formFeedback.textContent = "Uploading photo...";
try { try {
// Delete old photo if exists
if (state.currentPhotoPath) {
await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]);
}
const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const path = `${crypto.randomUUID()}.${ext}`; const path = `${crypto.randomUUID()}.${ext}`;
const { error: upErr } = await supabase.storage const { error: upErr } = await supabase.storage
.from("vehicle-photos") .from("vehicle-photos")
.upload(path, file, { contentType: file.type, upsert: false }); .upload(path, file, { contentType: file.type, upsert: true });
if (upErr) throw upErr; if (upErr) throw upErr;
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
state.currentPhotoPath = path; state.currentPhotoPath = path;
+12 -2
View File
@@ -196,6 +196,14 @@ function openDetails(id) {
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div> <div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div> <div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div> </div>
<div class="spec-row" style="margin:1rem 0;">
<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.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
</div>
<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>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem;">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div> <div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<button class="btn" id="dialogBook">${t("bookNow")}</button> <button class="btn" id="dialogBook">${t("bookNow")}</button>
@@ -364,8 +372,10 @@ function updateSidebar() {
const subtotal = weekdayCost + weekendCost; const subtotal = weekdayCost + weekendCost;
const vat = Math.round(subtotal * 0.20); const vat = Math.round(subtotal * 0.20);
const total = subtotal + vat; const total = subtotal + vat;
const deposit = Math.round(v.daily_price_eur * 2.5); const deposit = v.kaution_eur || 5000;
const includedKm = (v.max_daily_km || 150) * totalDays; const kmPerWeekendDay = v.max_km_weekend || v.max_daily_km || 150;
const kmPerWeekday = v.max_daily_km || 150;
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
bpfSidebarPlaceholder.style.display = "none"; bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block"; bpfSidebarContent.style.display = "block";
+4 -2
View File
@@ -133,7 +133,6 @@ export const translations = {
adminNewVehicle: "Neues Fahrzeug", adminNewVehicle: "Neues Fahrzeug",
adminAllVehicles: "Alle Fahrzeuge", adminAllVehicles: "Alle Fahrzeuge",
adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)", adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)",
adminPhotoUrl: "Foto-URL (wird automatisch gesetzt nach Upload)",
adminBrand: "Marke", adminBrand: "Marke",
adminModel: "Modell", adminModel: "Modell",
adminPower: "PS", adminPower: "PS",
@@ -169,6 +168,8 @@ export const translations = {
adminReceived: "Eingang", adminReceived: "Eingang",
adminVehicleTab: "Fahrzeug", adminVehicleTab: "Fahrzeug",
adminPeriod: "Zeitraum", adminPeriod: "Zeitraum",
adminKaution: "Kaution (€)",
adminMaxKmWeekend: "Max. km/Wochenendtag",
}, },
en: { en: {
navCars: "Fleet", navCars: "Fleet",
@@ -303,7 +304,6 @@ export const translations = {
adminNewVehicle: "New vehicle", adminNewVehicle: "New vehicle",
adminAllVehicles: "All vehicles", adminAllVehicles: "All vehicles",
adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)", adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)",
adminPhotoUrl: "Photo URL (auto-set after upload)",
adminBrand: "Brand", adminBrand: "Brand",
adminModel: "Model", adminModel: "Model",
adminPower: "HP", adminPower: "HP",
@@ -339,6 +339,8 @@ export const translations = {
adminReceived: "Received", adminReceived: "Received",
adminVehicleTab: "Vehicle", adminVehicleTab: "Vehicle",
adminPeriod: "Period", adminPeriod: "Period",
adminKaution: "Deposit (€)",
adminMaxKmWeekend: "Max. km/weekend day",
}, },
}; };
+8 -30
View File
@@ -65,6 +65,9 @@ begin
-- Let authenticator impersonate app roles. -- Let authenticator impersonate app roles.
execute 'grant anon, authenticated, service_role to authenticator'; execute 'grant anon, authenticated, service_role to authenticator';
-- Let storage_admin impersonate app roles (needed for RLS on storage.objects).
execute 'grant anon, authenticated, service_role to supabase_storage_admin';
end end
$roles$; $roles$;
@@ -165,34 +168,9 @@ insert into public.vehicles
(brand, model, power_hp, top_speed_kmh, acceleration, seats, (brand, model, power_hp, top_speed_kmh, acceleration, seats,
daily_price_eur, location, description_de, description_en, photo_url, sort_order) daily_price_eur, location, description_de, description_en, photo_url, sort_order)
values values
('Porsche','911 GT3', 510, 318, '3.4s', 2, 890, 'Steiermark (TBD)', ('Ferrari','296 GTB', 830, 330, '2.9s', 2, 850, 'Steiermark (TBD)',
'Puristischer Hochdrehzahl-Saugmotor und kompromissloser Motorsport-Charakter.', 'V6-Hybrid mit 830 PS, atemberaubendes Design und kompromisslose Performance auf Strasse und Rennstrecke.',
'Pure high-revving naturally aspirated engine with uncompromising motorsport character.', 'V6 hybrid with 830 hp, breathtaking design and uncompromising performance on road and track.',
'https://images.unsplash.com/photo-1611821064430-0d40291d0f0b?auto=format&fit=crop&w=1400&q=80', '/images/ferrari-main-car.png',
10), 10)
('Lamborghini','Huracan EVO', 640, 325, '2.9s', 2, 990, 'Steiermark (TBD)',
'V10 mit 640 PS, scharfes Design und kompromisslose Performance auf Strasse und Rennstrecke.',
'V10 with 640 hp, sharp design and uncompromising performance on road and track.',
'https://images.unsplash.com/photo-1544636331-e26879cd4d9b?auto=format&fit=crop&w=1400&q=80',
20),
('Audi','RS6 Performance', 630, 305, '3.4s', 5, 540, 'Steiermark (TBD)',
'Alltagstauglicher Kombi mit brutaler V8-Biturbo-Power und Allradantrieb.',
'Everyday-ready estate with brutal twin-turbo V8 power and quattro AWD.',
'https://images.unsplash.com/photo-1606664515524-ed2f786a0bd6?auto=format&fit=crop&w=1400&q=80',
30),
('BMW','M4 Competition', 530, 290, '3.5s', 4, 430, 'Steiermark (TBD)',
'Reihensechszylinder-Biturbo mit praeziser Lenkung und sportlichem Fahrwerk.',
'Twin-turbo inline-six with precise steering and sporty chassis.',
'https://images.unsplash.com/photo-1555215695-3004980ad54e?auto=format&fit=crop&w=1400&q=80',
40),
('Nissan','GT-R R35', 570, 315, '2.8s', 4, 510, 'Steiermark (TBD)',
'Ikonischer Allrad-Supersportler mit Twin-Turbo V6 und brutalem Antritt.',
'Iconic AWD supercar with twin-turbo V6 and ferocious launch.',
'https://images.unsplash.com/photo-1626668893632-6f3a4466d22f?auto=format&fit=crop&w=1400&q=80',
50),
('Mercedes-AMG','G63', 585, 220, '4.5s', 5, 620, 'Steiermark (TBD)',
'Legendaere G-Klasse mit V8-Biturbo-Performance und unverkennbarem Design.',
'Legendary G-Class with V8 biturbo performance and unmistakable design.',
'https://images.unsplash.com/photo-1606611013016-969c19ba27bb?auto=format&fit=crop&w=1400&q=80',
60)
on conflict do nothing; on conflict do nothing;
+6
View File
@@ -16,6 +16,12 @@ update public.vehicles
max_daily_km = 150 max_daily_km = 150
where weekend_price_eur = 0; where weekend_price_eur = 0;
-- Ferrari gets specific pricing
update public.vehicles
set weekend_price_eur = 1100,
max_daily_km = 200
where brand = 'Ferrari' and model = '296 GTB';
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 2. Lead attachments: documents uploaded during booking flow -- 2. Lead attachments: documents uploaded during booking flow
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
@@ -0,0 +1,27 @@
-- =============================================================================
-- MC Cars - Add per-vehicle kaution (deposit) and max_km_weekend columns.
-- Idempotent.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. kaution_eur: deposit per vehicle, NOT NULL, default 5000, must be > 0
-- -----------------------------------------------------------------------------
alter table public.vehicles add column if not exists kaution_eur integer not null default 5000;
do $$
begin
if not exists (
select 1 from information_schema.check_constraints
where constraint_name = 'vehicles_kaution_positive'
) then
alter table public.vehicles add constraint vehicles_kaution_positive check (kaution_eur > 0);
end if;
end $$;
-- -----------------------------------------------------------------------------
-- 2. max_km_weekend: optional per-weekend-day km allowance (NULL = use max_daily_km)
-- -----------------------------------------------------------------------------
alter table public.vehicles add column if not exists max_km_weekend integer default null;
-- Signal PostgREST to reload its schema cache
notify pgrst, 'reload schema';
+5
View File
@@ -60,9 +60,14 @@ on conflict (id) do update
file_size_limit = excluded.file_size_limit, file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types; allowed_mime_types = excluded.allowed_mime_types;
-- Let storage_admin assume app roles for RLS evaluation (idempotent).
grant anon, authenticated, service_role to supabase_storage_admin;
grant select on storage.buckets to anon, authenticated; grant select on storage.buckets to anon, authenticated;
grant all on storage.buckets to service_role;
grant select on storage.objects to anon; grant select on storage.objects to anon;
grant select, insert, update, delete on storage.objects to authenticated; grant select, insert, update, delete on storage.objects to authenticated;
grant all on storage.objects to service_role;
drop policy if exists "vehicle_photos_public_read" on storage.objects; drop policy if exists "vehicle_photos_public_read" on storage.objects;
drop policy if exists "vehicle_photos_admin_insert" on storage.objects; drop policy if exists "vehicle_photos_admin_insert" on storage.objects;