// Logistik — delivery price calculator + per-rental delivery + address book const { useState, useEffect } = React; // Package icon — isometric box, used for Logistik tab + headers const ICON_TRUCK = 'M12 4l8 4v8l-8 4-8-4V8l8-4z M4 8l8 4 8-4 M12 12v8'; const ICON_PIN = 'M12 22s-7-7.6-7-13a7 7 0 0114 0c0 5.4-7 13-7 13zM12 11a2 2 0 100-4 2 2 0 000 4z'; const DEFAULT_LOGISTIK = { kmRate: 0.70, pauschale: 10, tripFactor: 4, // 1 = nur Hinfahrt, 2 = hin & zurück, 4 = bringen + abholen (jeweils hin & zurück) lagerAddress: 'Bahnhofstr. 18, 82110 Germering', addressBook: [ { id: 'ab1', label: 'München · Zentrum', km: 18 }, { id: 'ab2', label: 'Germering · Ort', km: 4 }, { id: 'ab3', label: 'Planegg', km: 9 }, { id: 'ab4', label: 'Fürstenfeldbruck', km: 14 }, ], }; // Trip factor helper — supports legacy `returnTrip` boolean from older saved data const readTripFactor = (obj, fallback = 4) => { if (!obj) return fallback; if (typeof obj.tripFactor === 'number') return obj.tripFactor; if (typeof obj.returnTrip === 'boolean') return obj.returnTrip ? 2 : 1; return fallback; }; const TRIP_OPTIONS = [ { f: 1, label: 'Nur Hin', sub: '×1' }, { f: 2, label: 'Hin & zurück', sub: '×2' }, { f: 4, label: 'Bringen + Abholen', sub: '×4 · incl. Abbau' }, ]; const tripMeta = (f) => TRIP_OPTIONS.find(o => o.f === f) || TRIP_OPTIONS[2]; // Pure: returns the delivery price breakdown. Accepts "12,5" or "12.5" for km. function calcDelivery(km, tripFactor, rate, pauschale) { const k = Math.max(0, Number(String(km).replace(',', '.')) || 0); const f = Number(tripFactor) || 1; if (k === 0) return { tripKm: 0, totalKm: 0, kmCost: 0, pauschale: 0, total: 0, factor: f }; const totalKm = k * f; const kmCost = totalKm * rate; return { tripKm: k, totalKm, kmCost, pauschale, total: kmCost + pauschale, factor: f }; } // ─ Geocode + route helpers (OpenStreetMap public APIs, CORS-enabled) ─ async function geocodeAddress(query) { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&addressdetails=0`; const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error('Adress-Suche nicht erreichbar'); const j = await res.json(); if (!j || !j.length) throw new Error('„' + query + '“ nicht gefunden'); return { lat: parseFloat(j[0].lat), lon: parseFloat(j[0].lon), label: j[0].display_name }; } async function routeDistanceKm(from, to) { const url = `https://router.project-osrm.org/route/v1/driving/${from.lon},${from.lat};${to.lon},${to.lat}?overview=false`; const res = await fetch(url); if (!res.ok) throw new Error('Routing-Service nicht erreichbar'); const j = await res.json(); if (j.code !== 'Ok' || !j.routes || !j.routes.length) throw new Error('Keine Route gefunden'); return j.routes[0].distance / 1000; } const eu = (n) => n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; // Tiny CSS spinner for the address lookup button function Spinner({ color = '#fff', size = 12 }) { return ( ); } // ───────────────────────────────────────────────────────────── // Inline rate row — tap to edit a number with a unit suffix // ───────────────────────────────────────────────────────────── function RateRow({ label, value, suffix, step = 0.05, onChange }) { const t = useTheme(); const [editing, setEditing] = useState(false); const [tmp, setTmp] = useState(String(value).replace('.', ',')); useEffect(() => { setTmp(String(value).replace('.', ',')); }, [value]); const save = () => { setEditing(false); const n = Number(tmp.replace(',', '.')); if (!isNaN(n) && n >= 0) onChange(n); }; return (
{label}
{editing ? ( setTmp(e.target.value)} onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setTmp(String(value).replace('.', ',')); setEditing(false); } }} inputMode="decimal" style={{ width: 100, padding: '6px 10px', borderRadius: 8, border: `1px solid ${t.accent}`, background: t.inputBg, fontSize: 14, color: t.text, outline: 'none', textAlign: 'right', fontFamily: 'inherit' }}/> ) : ( setEditing(true)} scale={0.97}>
{String(value).replace('.', ',')} {suffix}
)}
); } // ───────────────────────────────────────────────────────────── // Address book sheet // ───────────────────────────────────────────────────────────── function AddressBookSheet({ open, onClose, logistik, setLogistik, toast }) { const t = useTheme(); const [label, setLabel] = useState(''); const [km, setKm] = useState(''); const add = () => { const l = label.trim(); const k = Number(km); if (!l || isNaN(k) || k <= 0) return; setLogistik(prev => ({ ...prev, addressBook: [...(prev.addressBook || []), { id: 'ab-' + Date.now(), label: l, km: k }] })); setLabel(''); setKm(''); toast('Adresse hinzugefügt'); }; const remove = (id) => setLogistik(prev => ({ ...prev, addressBook: prev.addressBook.filter(a => a.id !== id) })); const inp = { width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; return (
Adressbuch
Häufige Lieferorte mit Entfernung speichern.
Fertig
setLabel(e.target.value)} placeholder="Ort / Stichwort" style={inp}/> setKm(e.target.value)} placeholder="km" inputMode="decimal" style={{ ...inp, textAlign: 'center' }}/>
+ Adresse speichern
{(logistik.addressBook || []).map((a, i, arr) => (
{a.label}
{String(a.km).replace('.', ',')} km · einfache Strecke
remove(a.id)} scale={0.85}>
))} {(logistik.addressBook || []).length === 0 && (
Noch keine Adressen.
)}
); } // ───────────────────────────────────────────────────────────── // Settings sheet (km rate, Pauschale, Lagerstandort) // ───────────────────────────────────────────────────────────── function SettingsSheet({ open, onClose, logistik, setLogistik, toast }) { const t = useTheme(); const inp = { width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; return (
Tarife & Standort
Werden bei jeder Berechnung verwendet.
Fertig
setLogistik(p => ({ ...p, kmRate: v }))}/>
setLogistik(p => ({ ...p, pauschale: v }))}/>
Standard-Fahrten
{TRIP_OPTIONS.map(opt => { const sel = readTripFactor(logistik) === opt.f; return ( setLogistik(p => ({ ...p, tripFactor: opt.f, returnTrip: opt.f !== 1 }))} scale={0.98}>
{opt.label}
{opt.sub}
✕{opt.f}
); })}
Lager / Startadresse
setLogistik(p => ({ ...p, lagerAddress: e.target.value }))} placeholder="Bahnhofstr. 18, 82110 Germering" style={inp}/>
Von hier aus werden Entfernungen gemessen.
💡
Faustregel: Aktuell {String(logistik.kmRate).replace('.', ',')} €/km × {readTripFactor(logistik)} Fahrten + {logistik.pauschale} € Pauschale.
Beispiel 10 km: {eu(calcDelivery(10, readTripFactor(logistik), logistik.kmRate, logistik.pauschale).total)}
); } // ───────────────────────────────────────────────────────────── // Per-rental delivery sheet // ───────────────────────────────────────────────────────────── function RentalDeliverySheet({ open, onClose, rental, rentals, setRentals, logistik, toast }) { const t = useTheme(); const r = rentals.find(x => x.id === (rental && rental.id)); const defaultFactor = readTripFactor(logistik); const dRaw = (r && r.delivery) || { enabled: false, address: '', km: 0 }; const d = { ...dRaw, tripFactor: readTripFactor(dRaw, defaultFactor) }; const set = (patch) => { setRentals(prev => prev.map(x => x.id === r.id ? { ...x, delivery: { ...(x.delivery || { enabled: false, address: '', km: 0, tripFactor: defaultFactor }), ...patch, }, } : x)); }; const calc = calcDelivery(d.km, d.tripFactor, logistik.kmRate, logistik.pauschale); const inp = { width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; if (!r) return null; return (
Lieferung
{r.tenantName} · {r.equipmentName}
Fertig
{/* Enabled toggle */}
Anlieferung & Abholung
{d.enabled ? 'Wir liefern' : 'Selbstabholung'}
set({ enabled: !d.enabled })} scale={0.94}>
{d.enabled && ( <>
Lieferadresse
set({ address: e.target.value })} placeholder="Straße, PLZ Ort" style={inp}/> {(logistik.addressBook || []).length > 0 && (
{logistik.addressBook.map(a => ( set({ address: a.label, km: a.km })} scale={0.95}>
{a.label} · {String(a.km).replace('.', ',')} km
))}
)}
Entfernung
set({ km: Number(e.target.value) || 0 })} placeholder="km" inputMode="decimal" style={inp}/>
Fahrten
{TRIP_OPTIONS.map(opt => { const sel = d.tripFactor === opt.f; return ( set({ tripFactor: opt.f })} scale={0.95} style={{ flex: 1 }}>
×{opt.f}
); })}
{tripMeta(d.tripFactor).label} · {tripMeta(d.tripFactor).sub}
{/* Breakdown */}
1 ? `${String(calc.tripKm).replace('.', ',')} km × ${d.tripFactor}` : 'einfache Strecke'}/>
Lieferpreis gesamt
{eu(calc.total)}
{/* Copy line for sharing */} { toast('„Lieferung: ' + eu(calc.total) + '" kopiert'); }} scale={0.97} style={{ marginTop: 12 }}>
Text für Kunde kopieren
)}
); } function BreakdownRow({ label, value, sub }) { const t = useTheme(); return (
{label}
{sub &&
{sub}
}
{value}
); } // ───────────────────────────────────────────────────────────── // Main Logistik screen // ───────────────────────────────────────────────────────────── function ScreenLogistik({ rentals, setRentals, logistik, setLogistik, equipment, toast, go, embedded = false, settingsOpen: externalSettingsOpen, onSettingsClose }) { const t = useTheme(); const [km, setKm] = useState(''); const [tripFactor, setTripFactor] = useState(readTripFactor(logistik)); const [addrOpen, setAddrOpen] = useState(false); const [internalSettingsOpen, setInternalSettingsOpen] = useState(false); const settingsOpen = embedded ? !!externalSettingsOpen : internalSettingsOpen; const setSettingsOpen = embedded ? (v) => { if (!v && onSettingsClose) onSettingsClose(); } : setInternalSettingsOpen; const [rentalSheet, setRentalSheet] = useState(null); // rental id // Address → km lookup state const [address, setAddress] = useState(''); const [looking, setLooking] = useState(false); const [lookErr, setLookErr] = useState(''); const [resolvedLabel, setResolvedLabel] = useState(''); useEffect(() => { setTripFactor(readTripFactor(logistik)); }, [logistik.tripFactor, logistik.returnTrip]); const lookup = async () => { const q = address.trim(); if (!q) return; if (!logistik.lagerAddress) { setLookErr('Bitte zuerst Lager-Adresse in den Einstellungen eintragen.'); setSettingsOpen(true); return; } setLooking(true); setLookErr(''); setResolvedLabel(''); try { const from = await geocodeAddress(logistik.lagerAddress); const to = await geocodeAddress(q); const d = await routeDistanceKm(from, to); const rounded = Math.round(d * 10) / 10; setKm(String(rounded).replace('.', ',')); setResolvedLabel((to.label || '').split(',').slice(0, 3).join(',').trim()); toast(`${String(rounded).replace('.', ',')} km berechnet`); } catch (e) { setLookErr(e.message || 'Adresse konnte nicht berechnet werden'); } finally { setLooking(false); } }; const calc = calcDelivery(km, tripFactor, logistik.kmRate, logistik.pauschale); const liveRentals = rentals.filter(r => r.status !== 'abgeschlossen') .sort((a, b) => a.start.localeCompare(b.start)); // KPI: delivery revenue from rentals const deliveryRevenue = rentals .filter(r => r.delivery && r.delivery.enabled) .reduce((s, r) => s + calcDelivery(r.delivery.km, readTripFactor(r.delivery, readTripFactor(logistik)), logistik.kmRate, logistik.pauschale).total, 0); const deliveryCount = rentals.filter(r => r.delivery && r.delivery.enabled).length; return (
{/* Header — skipped in embedded mode */} {!embedded && (
Lieferpreis · Routen
Logistik
setSettingsOpen(true)} scale={0.88}>
)} {/* Calculator card */}
Lieferpreis-Rechner
{/* Address → km lookup */}
{ setAddress(e.target.value); setLookErr(''); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); lookup(); } }} placeholder="Adresse · Straße, PLZ Ort" style={{ width: '100%', padding: '11px 12px 11px 34px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 14, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }}/>
{address && !looking && ( { setAddress(''); setResolvedLabel(''); setLookErr(''); }} scale={0.85} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
)}
{looking ? (<> berechne…) : '→ km'}
{lookErr && (
{lookErr}
)} {resolvedLabel && !lookErr && (
✓ {resolvedLabel}
)} {/* km input */}
setKm(e.target.value)} placeholder="0" inputMode="decimal" autoFocus style={{ flex: 1, minWidth: 0, width: 0, padding: '4px 0', fontSize: 52, fontWeight: 700, letterSpacing: -1.6, border: 'none', outline: 'none', background: 'transparent', color: t.text, fontFamily: 'inherit', textAlign: 'right', }}/>
km
{/* Trip mode — 3 options */}
{TRIP_OPTIONS.map(opt => { const sel = tripFactor === opt.f; return ( setTripFactor(opt.f)} scale={0.96} style={{ flex: 1, minWidth: 0 }}>
{opt.label}
{opt.sub}
); })}
{/* Quick chips from address book */} {(logistik.addressBook || []).length > 0 && (
{logistik.addressBook.map(a => ( setKm(String(a.km).replace('.', ','))} scale={0.95}>
{a.label} · {String(a.km).replace('.', ',')} km
))} setAddrOpen(true)} scale={0.95}>
+ Adresse
)} {/* Breakdown */}
1 && calc.tripKm > 0 ? `${String(calc.tripKm).replace('.', ',')} km × ${tripFactor} Fahrten` : null}/>
Lieferpreis
{eu(calc.total)}
{calc.total > 0 && (
„Lieferung {eu(calc.total)} — {String(calc.totalKm).replace('.', ',')} km ({tripMeta(tripFactor).label.toLowerCase()}) bei {String(logistik.kmRate).replace('.', ',')} €/km + {logistik.pauschale} € Pauschale.“
)}
{/* KPI strip */}
setAddrOpen(true)} scale={0.97}>
Adressbuch
{(logistik.addressBook || []).length}
Verwalten ›
Lieferungen aktiv
{deliveryCount}
{eu(deliveryRevenue)} Umsatz
{/* Per-rental delivery list */}
Anstehende Lieferungen
{liveRentals.length === 0 && (
Keine laufenden Mieten.
)} {liveRentals.map(r => { const eq = equipment.find(e => e.id === r.equipmentId); const d = r.delivery; const enabled = d && d.enabled; const rCalc = enabled ? calcDelivery(d.km, readTripFactor(d, readTripFactor(logistik)), logistik.kmRate, logistik.pauschale) : null; const m = statusMeta(r.status); return ( setRentalSheet(r.id)} scale={0.98}>
{r.tenantName}
{fmtDateDE(r.start).slice(0, 6)} · {eq ? eq.name : r.equipmentName}
{enabled ? ( <>
🚚 {String(d.km).replace('.', ',')} km
{eu(rCalc.total)}
) : (
Selbstabholung
)}
); })}
r.id === rentalSheet)} rentals={rentals} setRentals={setRentals} logistik={logistik} onClose={() => setRentalSheet(null)} toast={toast}/> setAddrOpen(false)} logistik={logistik} setLogistik={setLogistik} toast={toast}/> setSettingsOpen(false)} logistik={logistik} setLogistik={setLogistik} toast={toast}/>
); } function BreakLine({ label, value, sub }) { const t = useTheme(); return (
{label}
{sub &&
{sub}
}
{value}
); } Object.assign(window, { ScreenLogistik, DEFAULT_LOGISTIK, calcDelivery, ICON_TRUCK });