// 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}>
);
})}
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
))}
)}
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 (
);
}
// ─────────────────────────────────────────────────────────────
// 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 */}
{/* 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 }}>
);
})}
{/* 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 */}
{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 (
);
}
Object.assign(window, { ScreenLogistik, DEFAULT_LOGISTIK, calcDelivery, ICON_TRUCK });