// ─── Mietvertrag-Workflow ─────────────────────────────────────
// Vier geführte Schritte + Übersicht. Wird für Neuanlage UND Bearbeiten
// benutzt (mode="new" | "edit") und liefert die Übersichtskarten, die in
// den Akten die Vertragsansicht bilden (RentalSummaryCards).
const { useState: useStateW, useEffect: useEffectW, useRef: useRefW } = React;
const WZ_STEPS = [
{ id: 'mieter', label: 'Mieter', title: 'Wer mietet?', sub: 'Name, Kontakt und Ausweis' },
{ id: 'zeit', label: 'Zeitraum', title: 'Wann & wie?', sub: 'Zeitraum, Uhrzeit, Abholung' },
{ id: 'equip', label: 'Equipment', title: 'Was wird gemietet?', sub: 'Geräte und Zubehör' },
{ id: 'preis', label: 'Preis', title: 'Preis & Status', sub: 'Kaution, Rabatt, Zahlung' },
{ id: 'check', label: 'Übersicht', title: 'Alles prüfen', sub: 'Letzter Blick vor dem Speichern' },
];
// ── kleine Bausteine ────────────────────────────────────────
function WzLabel({ t, children, top = 18 }) {
return
{children}
;
}
function WzCard({ t, children, pad = 0 }) {
return {children}
;
}
function WzRow({ t, label, value, hint, last }) {
return (
{label}
{value || '—'}
{hint ?
{hint}
: null}
);
}
// Fortschritt: Punkte, aktueller Schritt als Pille — antippbar (freies Springen)
function WzDots({ t, steps, idx, onJump, done }) {
const Pressable = window.Pressable;
return (
{steps.map((s, i) => {
const cur = i === idx;
const filled = cur || (i < idx && done(i));
return (
onJump(i)} scale={0.86}>
);
})}
);
}
// ── Verfügbarkeits-Block (Equipment im Zeitraum) ────────────
function WzAvailability({ t, rental, rentals = [], events = [], excludeRentalId }) {
const items = window.getRentalItems(rental) || [];
if (!items.length) return null;
const conflicts = items.flatMap((it) =>
(window.equipmentConflicts(it.equipmentId, rental.start, rental.end, rentals, events, excludeRentalId) || [])
.map((c) => ({ ...c, equipmentName: it.equipmentName })));
const free = conflicts.length === 0;
return (
{free ? 'Alle Positionen verfügbar' : 'Doppelbuchung – mind. eine Position belegt'}
{free
?
{items.length} Position{items.length > 1 ? 'en' : ''} im Zeitraum {window.fmtRange(rental.start, rental.end)} frei.
:
{conflicts.map((c) => (
{c.equipmentName} ·
{c.title}
· {window.fmtRange(c.start, c.end)}
))}
}
);
}
// ── Preisaufstellung (groß) ─────────────────────────────────
function WzPriceBlock({ t, rental, big = true }) {
const days = window.billableDays(rental);
const calDays = window.daysBetween(rental.start, rental.end);
const items = window.getRentalItems(rental) || [];
const sub = items.reduce((s, it) => s + days * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1), 0);
const accCost = window.rentalAccessoryCost ? window.rentalAccessoryCost(rental) : 0;
const deliveryFee = (rental.delivery && rental.delivery.enabled && window.rentalDeliveryFee) ? window.rentalDeliveryFee(rental) : 0;
const disc = Math.max(0, Number(rental.discount) || 0);
const computed = Math.max(0, sub + accCost + deliveryFee - disc);
const hasOverride = rental.priceOverride != null && rental.priceOverride !== '';
const total = hasOverride ? Math.max(0, Number(rental.priceOverride) || 0) : computed;
const line = (label, val, color) => (
);
return (
{line(`Equipment · ${days} Tag${days > 1 ? 'e' : ''}${days !== calDays ? ` (von ${calDays})` : ''}`, `${sub} €`)}
{accCost > 0 && line(`Zubehör · ${days} Tag${days > 1 ? 'e' : ''}`, `+ ${accCost} €`)}
{deliveryFee > 0 && line(`Lieferung${(rental.delivery || {}).feeMode === 'manual' ? ' · Festpreis' : ''}`, `+ ${Math.round(deliveryFee)} €`)}
{disc > 0 && line('Rabatt', `− ${disc} €`, t.red)}
{hasOverride && line('automatisch wäre', `${computed} €`, t.textTer)}
Gesamt
{Number(rental.deposit) > 0 &&
zzgl. {Number(rental.deposit)} € Kaution
}
{total} €
);
}
// ─────────────────────────────────────────────────────────────
// Übersichtskarten — Workflow-Schritt 5 UND Vertragsansicht in Akten
// ─────────────────────────────────────────────────────────────
function RentalSummaryCards({ rental, equipment = [], accessories = [], allRentals = [], events = [], blockedCustomers = [], onEditStep, onViewPhoto, excludeRentalId, inset = 0, showIdentity = true }) {
const t = window.useTheme();
const Pressable = window.Pressable;
const items = window.getRentalItems(rental) || [];
const accItems = rental.accessoryItems || [];
const del = rental.delivery || {};
const blockedHit = window.findBlockedCustomer(rental.tenantName, blockedCustomers);
const days = window.billableDays(rental);
const calDays = window.daysBetween(rental.start, rental.end);
const allDay = !rental.startTime && !rental.endTime;
const sub = items.reduce((s, it) => s + days * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1), 0);
const accCost = window.rentalAccessoryCost ? window.rentalAccessoryCost(rental) : 0;
const deliveryFee = (del.enabled && window.rentalDeliveryFee) ? Math.round(window.rentalDeliveryFee(rental)) : 0;
const disc = Math.max(0, Number(rental.discount) || 0);
const computed = Math.max(0, sub + accCost + deliveryFee - disc);
const hasOverride = rental.priceOverride != null && rental.priceOverride !== '';
const total = hasOverride ? Math.max(0, Number(rental.priceOverride) || 0) : computed;
const sm = window.statusMeta(rental.status);
const pm = window.paymentMeta(rental.paymentStatus || 'offen');
const dm = window.depositMeta(rental.depositStatus || 'offen');
// Einheitlicher Kartenkopf: gleiche Höhe, gleiche Typo, gleicher Bearbeiten-Button
const Head = ({ title, step }) => (
{title}
{onEditStep && step && (
onEditStep(step)} scale={0.92}>
Bearbeiten
)}
);
const Card = ({ title, step, children }) => (
{children}
);
// Kompakte Wertzeile — eine Typo-Größe für alle Labels, eine für alle Werte
const Line = ({ label, value, hint, strong, first, color }) => (
{label}
{value || '—'}
{hint ?
{hint}
: null}
);
return (
{/* Kopf — Preis und Zeitraum zuerst */}
{showIdentity && (
{rental.tenantName || 'Ohne Namen'}
)}
Zeitraum
{window.fmtRange(rental.start, rental.end)}
{calDays} Tag{calDays > 1 ? 'e' : ''}{days !== calDays ? ` · ${days} berechnet` : ''}{allDay ? ' · ganzer Tag' : ` · ${rental.startTime}–${rental.endTime}`}
{total} €
{hasOverride ? 'Festpreis' : 'Gesamt'}{Number(rental.deposit) > 0 ? ` · ${Number(rental.deposit)} € Kaution` : ''}
{[{ k: 'Miete', m: pm }, { k: 'Kaution', m: dm }].map(({ k, m }, i) => (
))}
{/* Equipment & Zubehör */}
{items.length === 0 && accItems.length === 0 ? (
Keine Positionen — der Vorgang läuft nur über die Notiz.
) : (
<>
{items.map((it, i) => (
{Math.max(1, Number(it.quantity) || 1)}×
{it.equipmentName}
{Number(it.dailyRate) || 0} €/Tag
{days * (Number(it.dailyRate) || 0) * Math.max(1, Number(it.quantity) || 1)} €
))}
{accItems.map((a, i) => (
{Math.max(1, Number(a.quantity) || 1)}×
0 ? t.text : t.textTer, fontVariantNumeric: 'tabular-nums' }}>
{(Number(a.dailyRate) || 0) > 0 ? `${days * Number(a.dailyRate) * Math.max(1, Number(a.quantity) || 1)} €` : 'inkl.'}
))}
{(() => {
const conflicts = items.flatMap((it) => (window.equipmentConflicts(it.equipmentId, rental.start, rental.end, allRentals, events, excludeRentalId) || []).map((c) => ({ ...c, equipmentName: it.equipmentName })));
if (!items.length) return null;
const free = conflicts.length === 0;
return (
{free ? 'Im Zeitraum verfügbar' : 'Doppelbuchung'}
{!free && conflicts.map((c) => (
{c.equipmentName} · {c.title} · {window.fmtRange(c.start, c.end)}
))}
);
})()}
>
)}
{/* Mieter */}
{rental.phone ? : null}
{rental.email ? : null}
{rental.address ? : null}
{!rental.phone && !rental.email && !rental.address ? : null}
{(rental.photos || []).length ? 1 ? 's' : ''}`} /> : null}
{blockedHit && (
Kunde ist gesperrt
{blockedHit.reason ?
{blockedHit.reason}
: null}
)}
{/* Abholung & Rückgabe */}
{del.enabled ? : null}
{del.enabled ? : null}
{!allDay ? : null}
{!allDay ? : null}
{rental.purpose ? : null}
{/* Preis im Detail */}
1 ? 'e' : ''}`} value={`${sub} €`} />
{accCost > 0 ? : null}
{deliveryFee > 0 ? : null}
{disc > 0 ? : null}
{Number(rental.deposit) > 0 ? : null}
{hasOverride ? : null}
{/* Notiz */}
{rental.note ? (
{rental.note}
) : null}
);
}
// ─────────────────────────────────────────────────────────────
// Der Workflow selbst
// ─────────────────────────────────────────────────────────────
function RentalWizard({
open, onClose, onSave, equipment = [], accessories = [], tabSettings = {}, rentals = [], events = [],
prefill = null, initial = null, mode = 'new', initialStep = 'mieter',
logistik = {}, company = {}, blockedCustomers = [], toast,
}) {
const t = window.useTheme();
const Pressable = window.Pressable;
const Sheet = window.Sheet;
const Icon = window.Icon;
const MiniRangePicker = window.MiniRangePicker;
const [palette] = window.useLocal('rf-event-colors', window.EVENT_COLORS);
const blank = {
tenantName: '', address: '', email: '', phone: '',
items: [], accessoryItems: [],
purpose: '', start: window.todayISO(), end: window.todayISO(),
billDays: '', startTime: '09:00', endTime: '18:00',
deposit: 100, discount: 0, note: '', color: '',
delivery: { enabled: false, km: '', address: '' },
status: 'reserviert', depositStatus: 'offen', paymentStatus: 'offen',
photos: [],
};
const [form, setForm] = useStateW(blank);
const [step, setStep] = useStateW(0);
const [showPicker, setShowPicker] = useStateW(true);
useEffectW(() => {
if (!open) return;
const base = { ...blank, ...(initial || prefill || {}) };
if ((!base.items || !base.items.length) && base.equipmentId) {
const eq = equipment.find((e) => e.id === base.equipmentId);
if (eq) base.items = [{ equipmentId: eq.id, equipmentName: eq.name, dailyRate: Number(base.dailyRate || eq.price) || 0, quantity: Math.max(1, Number(base.quantity) || 1) }];
}
if (!base.items) base.items = [];
setForm(base);
const i = WZ_STEPS.findIndex((s) => s.id === initialStep);
setStep(i < 0 ? 0 : i);
}, [open, initialStep]);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const hasItems = !!(form.items && form.items.length);
const hasNote = !!(form.note && form.note.trim());
const hasName = !!(form.tenantName || '').trim();
const valid = hasName && (hasItems || hasNote);
const isEdit = mode === 'edit';
const excludeId = isEdit ? (initial || {}).id : null;
// Schritt gilt als erledigt, sobald das Wesentliche drin ist
const stepDone = (i) => {
if (i === 0) return hasName;
if (i === 1) return !!form.start && !!form.end;
if (i === 2) return hasItems || hasNote;
if (i === 3) return valid;
return false;
};
// ── Ausweis-Fotos ──
const idPhotoRef = useRefW(null);
const [pendingSide, setPendingSide] = useStateW(null);
const triggerIdPhoto = (side) => { setPendingSide(side); if (idPhotoRef.current) { idPhotoRef.current.value = ''; idPhotoRef.current.click(); } };
const onIdPhotoFile = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const side = pendingSide; setPendingSide(null);
try {
const dataUrl = await window.compressImage(file, 900);
const label = side === 'front' ? 'Ausweis Vorderseite' : 'Ausweis Rückseite';
setForm((f) => ({ ...f, photos: [...(f.photos || []).filter((p) => p.label !== label), { id: 'ph-' + Date.now(), label, dataUrl, addedAt: window.todayDE() }] }));
} catch (err) { toast && toast('Foto konnte nicht geladen werden'); }
};
const removeIdPhoto = (side) => {
const label = side === 'front' ? 'Ausweis Vorderseite' : 'Ausweis Rückseite';
setForm((f) => ({ ...f, photos: (f.photos || []).filter((p) => p.label !== label) }));
};
const fInp = { width: '100%', padding: '13px 14px', borderRadius: 13, border: `0.5px solid ${t.inputBorder}`, background: t.card, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' };
const days = window.billableDays(form);
const calDays = window.daysBetween(form.start, form.end);
const blockedHit = window.findBlockedCustomer(form.tenantName, blockedCustomers);
const save = () => {
if (!valid) return;
const primary = (form.items && form.items[0]) || null;
const out = {
...(initial || {}),
...form,
id: (initial && initial.id) || 'r-' + Date.now(),
items: (form.items || []).map((it) => ({ equipmentId: it.equipmentId, equipmentName: it.equipmentName, dailyRate: Number(it.dailyRate) || 0, quantity: Math.max(1, Number(it.quantity) || 1) })),
billDays: (form.billDays === '' || Number(form.billDays) === calDays) ? '' : Math.max(1, Math.floor(Number(form.billDays) || 1)),
equipmentId: primary ? primary.equipmentId : '',
equipmentName: primary ? primary.equipmentName : '',
dailyRate: primary ? primary.dailyRate : 0,
quantity: primary ? primary.quantity : 1,
deposit: Number(form.deposit) || 0,
discount: Number(form.discount) || 0,
status: window.autoRentalStatus(form.start, form.end),
depositStatus: form.depositStatus || 'offen',
paymentStatus: form.paymentStatus || 'offen',
};
onSave && onSave(out);
onClose && onClose();
};
const cur = WZ_STEPS[step];
// ─── Schritt 1: Mieter ───
const StepMieter = (
);
// ─── Schritt 2: Zeitraum & Logistik ───
const deliveryOn = !!(form.delivery && form.delivery.enabled);
const kmRate = (logistik && logistik.kmRate) || 0.7;
const pauschale = (logistik && logistik.pauschale) || 10;
const tripFactor = (form.delivery && form.delivery.tripFactor) || (logistik && logistik.tripFactor) || 4;
const lookupKm = (addr) => {
const s = String(addr || '').trim().toLowerCase();
if (!s) return null;
const cities = { 'münchen': 24, 'augsburg': 58, 'starnberg': 9, 'fürstenfeldbruck': 7, 'gauting': 11, 'gilching': 8, 'pasing': 16, 'olching': 6, 'puchheim': 4, 'germering': 3 };
for (const [k, v] of Object.entries(cities)) if (s.includes(k)) return v;
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return 8 + (h % 38);
};
const deliveryCalc = (() => {
const k = Math.max(0, Number(String((form.delivery || {}).km || '').replace(',', '.')) || 0);
if (!k) return { totalKm: 0, total: 0 };
const totalKm = k * tripFactor;
return { totalKm, total: Math.round(totalKm * kmRate + pauschale) };
})();
const StepZeit = (
Zeitraum
setShowPicker((s) => !s)} scale={0.99}>
{window.fmtRange(form.start, form.end)}
{calDays} Tag{calDays > 1 ? 'e' : ''}
{showPicker && (
setForm((f) => ({ ...f, start: s, end: e }))} anchorISO={form.start} />
)}
Uhrzeit
Ganzer Tag
Uhrzeiten werden nicht erfasst
{(() => {
const allDay = !form.startTime && !form.endTime;
return (
setForm((f) => (allDay ? { ...f, startTime: '09:00', endTime: '18:00' } : { ...f, startTime: '', endTime: '' }))} scale={0.9}>
);
})()}
{(form.startTime || form.endTime) && [
{ key: 'startTime', label: deliveryOn ? 'Lieferzeitpunkt' : 'Abholung', date: form.start, fallback: '09:00' },
{ key: 'endTime', label: deliveryOn ? 'Abholzeit' : 'Rückgabe', date: form.end, fallback: '18:00' },
].map((r) => (
{r.label}
{window.fmtDateDE(r.date).slice(0, -5)}
set(r.key, e.target.value)}
style={{ padding: '7px 10px', borderRadius: 9, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, fontFamily: 'inherit', fontWeight: 600 }} />
))}
Abholung & Rückgabe
{[{ id: 'self', label: 'Kunde holt & bringt' }, { id: 'deliver', label: 'Wir liefern & holen ab' }].map(({ id, label }) => {
const on = (id === 'deliver') === deliveryOn;
return (
set('delivery', { ...(form.delivery || {}), enabled: id === 'deliver' })} scale={0.96} style={{ width: '100%' }}>
{label}
);
})}
{deliveryOn && (
)}
Zweck · optional
set('purpose', e.target.value)} placeholder="z. B. Hochzeit" style={fInp} />
);
// ─── Schritt 3: Equipment & Zubehör ───
const StepEquip = (
Equipment & Anzahl
set('items', v)} equipment={equipment}
days={days} calendarDays={calDays} billDays={form.billDays}
onBillDaysChange={(v) => set('billDays', v === '' ? '' : (v === calDays ? '' : v))}
rentals={rentals} events={events} rentalStart={form.start} rentalEnd={form.end}
excludeRentalId={excludeId}
showIcons={(tabSettings.equipment || {}).showIcons !== false} />
{accessories.length > 0 && (
<>
Zubehör
set('accessoryItems', v)}
accessories={accessories} equipmentItems={form.items || []} rentals={rentals}
rentalStart={form.start} rentalEnd={form.end} days={days}
excludeRentalId={excludeId}
showIcons={(tabSettings.zubehoer || {}).showIcons !== false} />
>
)}
{!hasItems && (
Ohne Equipment?
Dann ist im nächsten Schritt eine Notiz Pflicht — z. B. Beratung oder Reservierung für später.
)}
);
// ─── Schritt 4: Preis & Status ───
const StepPreis = (
Preis
Zahlung & Kaution
Mietbetrag
{window.PAYMENT_STEPS.map((s) => {
const pm = window.paymentMeta(s);
const sel = (form.paymentStatus || 'offen') === s;
return (
set('paymentStatus', s)} scale={0.95} style={{ flex: 1 }}>
{pm.short}
);
})}
Kaution
{window.DEPOSIT_STEPS.map((s) => {
const dm = window.depositMeta(s);
const sel = (form.depositStatus || 'offen') === s;
return (
set('depositStatus', s)} scale={0.95}>
{dm.short}
);
})}
Farbe im Kalender
{(palette || window.EVENT_COLORS).map((c, i) => {
const sel = (form.color || '') === c;
return (
set('color', c)} scale={0.9}>
);
})}
set('color', '')} scale={0.9}>
Auto
Notiz{!hasItems && · Pflicht ohne Equipment }
);
// ─── Schritt 5: Übersicht ───
const StepCheck = (
{!valid && (
Noch nicht speicherbar
{!hasName ? 'Der Name des Mieters fehlt.' : 'Ohne Equipment ist eine Notiz Pflicht.'}
)}
{(!(form.photos || []).length || !form.phone) && (
Optional offen: {[!(form.photos || []).length ? 'Ausweis-Fotos' : null, !form.phone ? 'Telefon' : null, !form.email ? 'E-Mail' : null].filter(Boolean).join(' · ')}
)}
setStep(WZ_STEPS.findIndex((s) => s.id === id))} />
);
const body = [StepMieter, StepZeit, StepEquip, StepPreis, StepCheck][step];
const isLast = step === WZ_STEPS.length - 1;
const primaryLabel = isLast
? (blockedHit ? 'Trotz Sperre speichern' : (isEdit ? 'Änderungen speichern' : 'Mietvertrag anlegen'))
: 'Weiter';
const primaryBg = isLast ? (!valid ? t.chip : (blockedHit ? t.red : t.solidBg)) : t.solidBg;
return (
{isLast ? 'Übersicht' : `Schritt ${step + 1} von 4`}{isEdit ? ' · Bearbeiten' : ''}
{cur.title}
{cur.sub}
{body}
{step > 0 && (
setStep((s) => Math.max(0, s - 1))} scale={0.96}>
Zurück
)}
(isLast ? save() : setStep((s) => Math.min(WZ_STEPS.length - 1, s + 1)))} scale={0.98} style={{ flex: 1 }}>
{primaryLabel}
);
}
Object.assign(window, { RentalWizard, RentalSummaryCards, WZ_STEPS, WzPriceBlock, WzAvailability });