const { useState, useEffect, useMemo, useCallback, useRef } = React; const { createRoot } = ReactDOM; const { Bell, BellRing, Plus, X, Check, Clock, MapPin, User, Users, LogOut, AlertCircle, Sparkles, Filter, Lock, Paperclip, FileText, Menu, CalendarDays, ClipboardList, Settings, Share, Sun, Moon, Smartphone, Phone, Navigation, Radar } = Icons; // ---------- Design tokens (Breakin' Circus) ---------- // bg-void #140F24 | surface #1E1633 | surface-2 #281D42 | line #3A2C5A // text-hi #F7F1F8 | text-mid #BBAED6 | text-low #7C6E9C // accent (breakin pink) #E0287D | accent-2 (street violet) #7A2BB0 | in Arbeit #45C8D0 | done #6FBF7A | overdue #FF5A4F const HEADER_IMG = "header.jpg"; const APP_ICON = "apple-touch-icon.png"; const ROUTE_MAP_SEED = {}; const uid = () => Math.random().toString(36).slice(2, 10); const MAX_ATTACHMENTS = 3; const MAX_FILE_MB = 4; function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // Bilder client-seitig verkleinern, damit die geteilte Ablage nicht zu groß wird. PDFs bleiben unverändert. function compressImage(dataUrl, maxDim = 1280, quality = 0.75) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { let { width, height } = img; if (width > maxDim || height > maxDim) { const scale = maxDim / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; canvas.getContext("2d").drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL("image/jpeg", quality)); }; img.onerror = () => resolve(dataUrl); img.src = dataUrl; }); } function AttachmentPicker({ attachments, setAttachments }) { const [error, setError] = useState(""); const handleFiles = async (e) => { const files = Array.from(e.target.files || []); e.target.value = ""; setError(""); if (attachments.length + files.length > MAX_ATTACHMENTS) { setError(`Maximal ${MAX_ATTACHMENTS} Anhänge pro Auftrag.`); return; } for (const file of files) { if (file.size > MAX_FILE_MB * 1024 * 1024) { setError(`"${file.name}" ist größer als ${MAX_FILE_MB} MB und wurde übersprungen.`); continue; } const isImage = file.type.startsWith("image/"); let dataUrl = await readFileAsDataUrl(file); if (isImage) dataUrl = await compressImage(dataUrl); setAttachments((prev) => [...prev, { id: uid(), name: file.name, type: isImage ? "image" : "pdf", dataUrl }]); } }; const remove = (id) => setAttachments((prev) => prev.filter((a) => a.id !== id)); return (
Anhang (Bild oder PDF, optional) {error &&

{error}

} {attachments.length > 0 && (
{attachments.map((a) => (
{a.type === "image" ? ( {a.name} ) : ( )} {a.name}
))}
)}
); } // Tourplan "Breakin' Circus – Urban Dance & Artistic Show" Tournee 2026 (DDC Entertainment). // type "show" = Auftrittstag (ggf. mehrere Vorstellungen), type "off" = freier Tag ohne Auftritt. // Hotels sind noch offen (hotelAddress: null) und können später ergänzt werden. const STOPS_SEED = [ { id: "b01", type: "show", city: "Bielefeld", arena: "Stadthalle", date: "2026-11-10", times: ["20:00"], venueAddress: { name: "Stadthalle Bielefeld", street: "Willy-Brandt-Platz 1", zip: "33602", city: "Bielefeld" }, hotelAddress: null }, { id: "b02", type: "show", city: "Duisburg", arena: "Theater am Marientor", date: "2026-11-11", times: ["20:00"], venueAddress: { name: "Theater am Marientor", street: "Plessingstraße 20", zip: "47051", city: "Duisburg" }, hotelAddress: null }, { id: "b03", type: "show", city: "Schwerin", arena: "Stadthalle", date: "2026-11-12", times: ["19:30"], venueAddress: { name: "Stadthalle Schwerin (Sport- und Kongresshalle)", street: "Wittenburger Straße 118", zip: "19059", city: "Schwerin" }, hotelAddress: null }, { id: "b04", type: "show", city: "Berlin", arena: "Admiralspalast", date: "2026-11-13", times: ["16:00", "20:00"], venueAddress: { name: "Admiralspalast", street: "Friedrichstraße 101", zip: "10117", city: "Berlin" }, hotelAddress: null }, { id: "b05", type: "show", city: "Landshut", arena: "ESKARA Halle (Essenbach)", date: "2026-11-14", times: ["20:00"], venueAddress: { name: "ESKARA Sport- und Kulturarena", street: "", zip: "84051", city: "Essenbach" }, hotelAddress: null }, { id: "b06", type: "show", city: "Hof", arena: "Freiheitshalle", date: "2026-11-15", times: ["16:00"], venueAddress: { name: "Freiheitshalle Hof", street: "Kulmbacher Straße 4", zip: "95030", city: "Hof" }, hotelAddress: null }, { id: "b07", type: "show", city: "Stuttgart", arena: "Liederhalle", date: "2026-11-16", times: ["16:00", "20:00"], venueAddress: { name: "KKL Liederhalle (Beethovensaal)", street: "Berliner Platz 1–3", zip: "70174", city: "Stuttgart" }, hotelAddress: null }, { id: "bf01", type: "off", city: "OffDay", arena: "", date: "2026-11-17", times: [], venueAddress: null, hotelAddress: null }, { id: "b08", type: "show", city: "Offenbach", arena: "Stadthalle", date: "2026-11-18", times: ["20:00"], venueAddress: { name: "Stadthalle Offenbach", street: "Waldstraße 312", zip: "63071", city: "Offenbach am Main" }, hotelAddress: null }, { id: "b09", type: "show", city: "Aschaffenburg", arena: "Stadthalle am Schloss", date: "2026-11-19", times: ["20:00"], venueAddress: { name: "Stadthalle am Schloss", street: "Schloßplatz 1", zip: "63739", city: "Aschaffenburg" }, hotelAddress: null }, { id: "b10", type: "show", city: "Neu-Ulm", arena: "ratiopharm arena", date: "2026-11-20", times: ["19:30"], venueAddress: { name: "ratiopharm arena", street: "Europastraße 25", zip: "89231", city: "Neu-Ulm" }, hotelAddress: null }, { id: "b11", type: "show", city: "Nürnberg", arena: "Meistersingerhalle", date: "2026-11-21", times: ["16:00", "20:00"], venueAddress: { name: "Meistersingerhalle", street: "Münchener Straße 21", zip: "90478", city: "Nürnberg" }, hotelAddress: null }, { id: "b12", type: "show", city: "Würzburg", arena: "tectake Arena", date: "2026-11-22", times: ["16:00"], venueAddress: { name: "tectake Arena", street: "Stettiner Straße 1", zip: "97072", city: "Würzburg" }, hotelAddress: null }, { id: "bf02", type: "off", city: "OffDay", arena: "", date: "2026-11-23", times: [], venueAddress: null, hotelAddress: null }, { id: "b13", type: "show", city: "Jena", arena: "Sparkassen-Arena", date: "2026-11-24", times: ["19:30"], venueAddress: { name: "Sparkassen-Arena", street: "Keßlerstraße 28", zip: "07745", city: "Jena" }, hotelAddress: null }, { id: "b14", type: "show", city: "Bochum", arena: "RuhrCongress", date: "2026-11-25", times: ["19:30"], venueAddress: { name: "RuhrCongress Bochum", street: "Stadionring 20", zip: "44791", city: "Bochum" }, hotelAddress: null }, { id: "b15", type: "show", city: "Lübeck", arena: "Musik- und Kongresshalle", date: "2026-11-26", times: ["19:30"], venueAddress: { name: "Musik- und Kongresshalle (MuK)", street: "Willy-Brandt-Allee 10", zip: "23554", city: "Lübeck" }, hotelAddress: null }, { id: "b16", type: "show", city: "Dresden", arena: "Messe Dresden", date: "2026-11-27", times: ["19:30"], venueAddress: { name: "Messe Dresden (Halle 3)", street: "Messering 6", zip: "01067", city: "Dresden" }, hotelAddress: null }, { id: "b17", type: "show", city: "Düsseldorf", arena: "Castello", date: "2026-11-28", times: ["20:00"], venueAddress: { name: "Castello Düsseldorf", street: "Karl-Hohmann-Straße 1", zip: "40599", city: "Düsseldorf" }, hotelAddress: null }, { id: "b18", type: "show", city: "Hannover", arena: "Swiss Life Hall", date: "2026-11-29", times: ["16:00"], venueAddress: { name: "Swiss Life Hall", street: "Ferdinand-Wilhelm-Fricke-Weg 8", zip: "30169", city: "Hannover" }, hotelAddress: null }, { id: "bf03", type: "off", city: "OffDay", arena: "", date: "2026-11-30", times: [], venueAddress: null, hotelAddress: null }, { id: "b19", type: "show", city: "Göttingen", arena: "Stadthalle", date: "2026-12-01", times: ["20:00"], venueAddress: { name: "Stadthalle Göttingen", street: "Albaniplatz 2", zip: "37073", city: "Göttingen" }, hotelAddress: null }, { id: "b20", type: "show", city: "Bremen", arena: "Metropol Theater", date: "2026-12-02", times: ["20:00"], venueAddress: { name: "Metropol Theater Bremen", street: "Richtweg 7", zip: "28195", city: "Bremen" }, hotelAddress: null }, { id: "b21", type: "show", city: "Dortmund", arena: "Westfalenhalle 2", date: "2026-12-03", times: ["20:00"], venueAddress: { name: "Westfalenhalle 2", street: "Rheinlanddamm 200", zip: "44139", city: "Dortmund" }, hotelAddress: null }, { id: "b22", type: "show", city: "Ludwigshafen", arena: "Friedrich-Ebert-Halle", date: "2026-12-04", times: ["20:00"], venueAddress: { name: "Friedrich-Ebert-Halle", street: "Erzbergerstraße 89", zip: "67063", city: "Ludwigshafen am Rhein" }, hotelAddress: null }, { id: "b23", type: "show", city: "Suhl", arena: "Congress Centrum", date: "2026-12-05", times: ["20:00"], venueAddress: { name: "CCS Congress Centrum Suhl", street: "Friedrich-König-Straße 7", zip: "98527", city: "Suhl" }, hotelAddress: null }, ]; function formatTimes(times) { if (!times || times.length === 0) return ""; return times.map((t) => (t.endsWith(":00") ? t.slice(0, 2).replace(/^0/, "") : t)).join(" & ") + " Uhr"; } // Adresse als eine Zeile, leere Teile (z. B. fehlende Straße) werden ausgelassen. function formatAddress(a) { if (!a) return ""; const cityLine = [a.zip, a.city].filter(Boolean).join(" "); return [a.name, a.street, cityLine].filter(Boolean).join(", "); } const TEAM_SEED = [ { id: "u1", name: "Daniel", role: "Fahrer 1", pin: null, isLeader: true }, { id: "u2", name: "Marieke", role: "Fahrer 2", pin: null }, ]; const PRIORITIES = { sofort: { label: "Sofort", sub: "Notfall", color: "#FF5A4F" }, wichtig: { label: "Wichtig", sub: "30–60 Minuten", color: "#E7A73F" }, normal: { label: "Normal", sub: "1–3 Stunden", color: "#E0287D" }, flexibel: { label: "Flexibel", sub: "heute", color: "#6FBF7A" }, anderer_tag: { label: "Anderer Tag", sub: "Datum wählen", color: "#7C6E9C" }, }; const PRIORITY_ORDER = ["sofort", "wichtig", "normal", "flexibel", "anderer_tag"]; // Zeitfenster je Dringlichkeit in Minuten ab Erstellung (sofort/wichtig/normal), // "flexibel" = Ende des Erstelltages, "anderer_tag" = Ende des gewählten Datums. const PRIORITY_MINUTES = { sofort: 0, wichtig: 60, normal: 180 }; function computeDeadline(task) { const created = task.createdAt || Date.now(); let deadlineTs; let dateOnly = false; if (task.priority in PRIORITY_MINUTES) { deadlineTs = created + PRIORITY_MINUTES[task.priority] * 60000; } else if (task.priority === "flexibel") { const d = new Date(created); d.setHours(23, 59, 0, 0); deadlineTs = d.getTime(); } else if (task.priority === "anderer_tag" && task.due) { deadlineTs = new Date(task.due + "T23:59:00").getTime(); dateOnly = true; } else { return null; } const now = Date.now(); const overdue = task.status !== "erledigt" && now > deadlineTs; const deadlineDate = new Date(deadlineTs); const label = task.priority === "sofort" ? "sofort" : dateOnly ? `bis ${deadlineDate.toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}` : `bis ${deadlineDate.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`; return { deadlineTs, overdue, label }; } const TASK_TYPES = ["Shuttle/Fahrt", "Artist-Transfer", "Besorgung", "Arzt", "Hotel", "Technik/Material", "Sonstiges"]; const STATUSES = { offen: { label: "Offen", short: "Offen", color: "#7C6E9C" }, in_arbeit: { label: "In Arbeit", short: "In Arbeit", color: "#45C8D0" }, erledigt: { label: "Fertig", short: "Fertig", color: "#6FBF7A" }, }; const STATUS_ORDER = ["offen", "in_arbeit", "erledigt"]; const nextStatus = (s) => STATUS_ORDER[(STATUS_ORDER.indexOf(s) + 1) % STATUS_ORDER.length]; function useStorage() { const [tasks, setTasks] = useState([]); const [team, setTeam] = useState(TEAM_SEED); const [stops, setStops] = useState(STOPS_SEED); const [routeMaps, setRouteMaps] = useState(ROUTE_MAP_SEED); const [loaded, setLoaded] = useState(false); useEffect(() => { (async () => { try { const t = await window.storage.get("el-tasks", true); if (t?.value) setTasks(JSON.parse(t.value)); } catch (e) {} try { const tm = await window.storage.get("el-team-v5", true); const tmList = tm?.value ? JSON.parse(tm.value) : null; if (Array.isArray(tmList) && tmList.length > 0) setTeam(tmList); else await window.storage.set("el-team-v5", JSON.stringify(TEAM_SEED), true); } catch (e) {} try { const st = await window.storage.get("el-stops-v8", true); const stList = st?.value ? JSON.parse(st.value) : null; if (Array.isArray(stList) && stList.length > 0) setStops(stList); else await window.storage.set("el-stops-v8", JSON.stringify(STOPS_SEED), true); } catch (e) {} try { const rm = await window.storage.get("el-routemaps-v1", true); const rmObj = rm?.value ? JSON.parse(rm.value) : null; if (rmObj && !Array.isArray(rmObj)) setRouteMaps(rmObj); } catch (e) {} setLoaded(true); })(); }, []); const saveTasks = useCallback(async (next) => { setTasks(next); try { await window.storage.set("el-tasks", JSON.stringify(next), true); } catch (e) {} }, []); const saveStops = useCallback(async (next) => { setStops(next); try { await window.storage.set("el-stops-v8", JSON.stringify(next), true); } catch (e) {} }, []); const saveTeam = useCallback(async (next) => { setTeam(next); try { await window.storage.set("el-team-v5", JSON.stringify(next), true); } catch (e) {} }, []); const saveRouteMap = useCallback(async (stopId, dataUrl) => { setRouteMaps((prev) => { const next = { ...prev, [stopId]: dataUrl }; window.storage.set("el-routemaps-v1", JSON.stringify(next), true).catch(() => {}); return next; }); }, []); const sortedStops = useMemo(() => [...stops].sort((a, b) => a.date.localeCompare(b.date)), [stops]); return { tasks, saveTasks, team, saveTeam, stops: sortedStops, saveStops, routeMaps, saveRouteMap, loaded }; } function daysUntil(dateStr) { if (!dateStr) return null; const d = new Date(dateStr + "T00:00:00"); const now = new Date(); now.setHours(0, 0, 0, 0); return Math.round((d - now) / 86400000); } const todayStr = () => new Date().toISOString().slice(0, 10); // Entfernung zweier Koordinaten in km (Haversine) function distanceKm(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function relativeTime(ts) { if (!ts) return null; const diffSec = Math.round((Date.now() - ts) / 1000); if (diffSec < 60) return "gerade eben"; const min = Math.round(diffSec / 60); if (min < 60) return `vor ${min} Min`; const hrs = Math.round(min / 60); if (hrs < 24) return `vor ${hrs} Std`; const days = Math.round(hrs / 24); return `vor ${days} Tag(en)`; } // Findet die Tour-Station, die zu einem Datum gehört. Fällt auf die nächste kommende // (oder sonst die letzte) Station zurück, falls das Datum außerhalb der Tour liegt. function findStopForDate(dateStr, stops) { return ( stops.find((s) => s.date === dateStr) || stops.find((s) => s.date >= dateStr) || stops[stops.length - 1] ); } // Berechnet "Show X von Y" (pro einzelner Vorstellung, Doppelshow-Tage zählen doppelt) // und "Stadt A von B" relativ zum heutigen Tag. Liegt heute auf einem OffDay, wird auf // die nächste (bzw. nach Tourende die letzte) Show zurückgefallen. Vor dem eigentlichen // Tourstart läuft der Zähler noch nicht – stattdessen gibt es einen Start-Countdown. function computeTourCounters(stops) { const shows = stops.filter((s) => s.type === "show"); if (shows.length === 0) return null; const today = todayStr(); const firstShow = shows[0]; if (today < firstShow.date) { return { started: false, firstShow, daysUntilStart: daysUntil(firstShow.date) }; } let showStop = shows.find((s) => s.date === today) || shows.find((s) => s.date >= today) || shows[shows.length - 1]; // Jede einzelne Vorstellung (Uhrzeit) als eigenen Zähl-Schritt behandeln const performances = []; shows.forEach((s) => (s.times.length ? s.times : [null]).forEach(() => performances.push(s.id))); const performanceTotal = performances.length; const positionsToday = performances.reduce((acc, id, i) => (id === showStop.id ? [...acc, i + 1] : acc), []); const perfFirst = positionsToday[0]; const perfLast = positionsToday[positionsToday.length - 1]; const showLabel = perfFirst === perfLast ? `${perfFirst}` : `${perfFirst}–${perfLast}`; const cityOrder = []; for (const s of stops) { if (s.type === "show" && !cityOrder.includes(s.city)) cityOrder.push(s.city); if (s.id === showStop.id) break; } const cityTotal = new Set(shows.map((s) => s.city)).size; const cityIndex = cityOrder.length; return { started: true, showStop, showLabel, showTotal: performanceTotal, cityIndex, cityTotal, isToday: showStop.date === today }; } // ---------- Login ---------- function PinPad({ value, onChange, length = 4 }) { const digits = value.split(""); return (
{Array.from({ length }).map((_, i) => ( ))} onChange(e.target.value.replace(/\D/g, "").slice(0, length))} />
); } function ExternalTaskModal({ stops, team, onClose, onSubmit, sent }) { const [title, setTitle] = useState(""); const [desc, setDesc] = useState(""); const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const [taskType, setTaskType] = useState(TASK_TYPES[0]); const [priority, setPriority] = useState("normal"); const [due, setDue] = useState(""); const [attachments, setAttachments] = useState([]); const [touched, setTouched] = useState(false); const leader = team.find((m) => m.isLeader) || team[0]; const missingRequired = !title.trim() || !desc.trim() || !name.trim() || !phone.trim() || (priority === "anderer_tag" && !due); const submit = (e) => { e.preventDefault(); setTouched(true); if (missingRequired) return; const effectiveDue = priority === "anderer_tag" ? due : todayStr(); const stop = findStopForDate(effectiveDue, stops); onSubmit({ id: uid(), title: title.trim(), desc: desc.trim(), assignee: leader?.id || "", stopId: stop?.id || stops[0]?.id, priority, due: effectiveDue, status: "offen", taskType, createdBy: null, submittedBy: name.trim(), contactName: name.trim(), contactPhone: phone.trim(), external: true, attachments, createdAt: Date.now(), }); }; return (
e.stopPropagation()} onSubmit={submit}>
Auftrag einreichen
{sent ? (

Danke! Der Auftrag ist eingegangen und wird von {leader?.name || "der Leitung"} gesichtet und zugewiesen.

) : ( <>

Für externe Anfragen ohne eigenen Zugang – geht direkt an {leader?.name || "die Leitung"} und wird von dort verteilt.