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)
Datei auswählen
{error &&
{error}
}
{attachments.length > 0 && (
{attachments.map((a) => (
{a.type === "image" ? (
) : (
)}
{a.name}
remove(a.id)} aria-label="Anhang entfernen">
))}
)}
);
}
// 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 (
);
}
function LoginScreen({ team, onLogin, onCreateMember, stops, onExternalTask }) {
const [selected, setSelected] = useState(null); // member being logged in / set up
const [mode, setMode] = useState(null); // 'login' | 'setup'
const [pin, setPin] = useState("");
const [pin2, setPin2] = useState("");
const [error, setError] = useState("");
const [externalOpen, setExternalOpen] = useState(false);
const [externalSent, setExternalSent] = useState(false);
const openMember = (m) => {
setSelected(m);
setMode(m.pin ? "login" : "setup");
setPin("");
setPin2("");
setError("");
};
const closeOverlay = () => {
setSelected(null);
setMode(null);
setPin("");
setPin2("");
setError("");
};
const submitLogin = () => {
if (pin === selected.pin) {
onLogin(selected);
} else {
setError("PIN stimmt nicht.");
setPin("");
}
};
const submitSetup = () => {
if (pin.length !== 4) {
setError("PIN braucht 4 Ziffern.");
return;
}
if (pin !== pin2) {
setError("PINs stimmen nicht überein.");
setPin2("");
return;
}
onCreateMember({ ...selected, pin }, true);
};
const tourCounters = useMemo(() => computeTourCounters(stops), [stops]);
return (
{tourCounters && !tourCounters.started && (
Tour startet in {tourCounters.daysUntilStart} Tag{tourCounters.daysUntilStart === 1 ? "" : "en"}
{tourCounters.firstShow.city} · {new Date(tourCounters.firstShow.date + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short", year: "numeric" })}
)}
{tourCounters && tourCounters.started && (
Show {tourCounters.showLabel}/{tourCounters.showTotal}
{tourCounters.isToday ? "heute" : `nächste · ${new Date(tourCounters.showStop.date + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}`}
Stadt {tourCounters.cityIndex}/{tourCounters.cityTotal}
{tourCounters.showStop.city}
)}
{ setExternalOpen(true); setExternalSent(false); }}>
Auftrag einreichen
Ansprechpartner: Daniel +49 170 5532534
{!selected && (
<>
Anmeldung für Fahrteam
{team.map((m) => (
openMember(m)}>
{m.name.charAt(0)}
{m.name}
{m.role}
{!m.pin && Konto einrichten }
))}
>
)}
{selected && (
← Andere Person
{selected.name.charAt(0)}
{selected.name}
{selected.role}
{mode === "login" && (
<>
PIN eingeben
{ setPin(v); setError(""); }} />
{error && {error}
}
Anmelden
>
)}
{mode === "setup" && (
<>
Neue PIN festlegen (4 Ziffern)
{ setPin(v); setError(""); }} />
{pin.length === 4 && (
<>
PIN bestätigen
{ setPin2(v); setError(""); }} />
>
)}
{error && {error}
}
Konto anlegen & anmelden
>
)}
)}
{externalOpen && (
setExternalOpen(false)}
onSubmit={(task) => { onExternalTask(task); setExternalSent(true); }}
sent={externalSent}
/>
)}
);
}
// ---------- Main app pieces ----------
function AddStopModal({ open, onClose, onCreate }) {
const [isOff, setIsOff] = useState(false);
const [city, setCity] = useState("");
const [arena, setArena] = useState("");
const [date, setDate] = useState("");
const [times, setTimes] = useState("");
if (!open) return null;
const submit = (e) => {
e.preventDefault();
if (!date) return;
if (!isOff && !city.trim()) return;
onCreate({
id: uid(),
type: isOff ? "off" : "show",
city: isOff ? "OffDay" : city.trim(),
arena: isOff ? "" : arena.trim(),
date,
times: isOff ? [] : times.split(",").map((t) => t.trim()).filter(Boolean),
});
setCity("");
setArena("");
setDate("");
setTimes("");
setIsOff(false);
onClose();
};
return (
);
}
function RouteStrip({ stops, activeStopId, onSelect, onAdd }) {
return (
{stops.map((s, i) => {
const du = daysUntil(s.date);
const isPast = du !== null && du < 0;
const isActive = s.id === activeStopId;
const isOff = s.type !== "show";
return (
onSelect(s.id)}
className={`route-stop ${isActive ? "is-active" : ""} ${isPast ? "is-past" : ""} ${isOff ? "is-off" : ""}`}
style={{ animationDelay: `${i * 30}ms` }}
>
{s.city}
{new Date(s.date + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}
{!isOff && {formatTimes(s.times)} }
);
})}
);
}
function NewTaskModal({ open, onClose, onCreate, team, stops, defaultStopId, currentUser }) {
const [title, setTitle] = useState("");
const [desc, setDesc] = useState("");
const [assignee, setAssignee] = useState(team[0]?.id || "");
const [stopId, setStopId] = useState(defaultStopId || stops[0]?.id || "");
const [priority, setPriority] = useState("mittel");
const [due, setDue] = useState("");
const [attachments, setAttachments] = useState([]);
useEffect(() => {
if (open) {
setStopId(defaultStopId || stops[0]?.id || "");
setAssignee(currentUser?.id || team[0]?.id || "");
setAttachments([]);
}
}, [open, defaultStopId, stops, team, currentUser]);
if (!open) return null;
const submit = (e) => {
e.preventDefault();
if (!title.trim() || !assignee || !stopId) return;
onCreate({
id: uid(),
title: title.trim(),
desc: desc.trim(),
assignee,
stopId,
priority,
due,
status: "offen",
createdBy: currentUser?.id || null,
attachments,
createdAt: Date.now(),
});
setTitle("");
setDesc("");
setDue("");
setAttachments([]);
onClose();
};
return (
);
}
function TaskCard({ task, member, stop, team, canReassign, canDelete, onToggle, onDelete, onAssign, onBroadcast }) {
const prio = PRIORITIES[task.priority] || PRIORITIES.normal;
const st = STATUSES[task.status] || STATUSES.offen;
const deadlineInfo = computeDeadline(task);
const overdue = deadlineInfo?.overdue || false;
const unassigned = !task.assignee;
const showReassign = unassigned || canReassign;
const showBroadcast = canReassign && !task.broadcastId && onBroadcast;
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef(null);
const handleDeleteClick = () => {
if (confirmDelete) {
clearTimeout(confirmTimer.current);
onDelete(task.id);
} else {
setConfirmDelete(true);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
}
};
useEffect(() => () => clearTimeout(confirmTimer.current), []);
return (
onToggle(task.id)}
aria-label="Status umschalten"
title="Tippen, um zum nächsten Status zu wechseln"
>
{task.status === "erledigt" ? : }
onToggle(task.id)}>
{task.status === "erledigt" && }
{st.short}
{deadlineInfo && (
{overdue &&
}
{overdue ? "Überfällig" : prio.label} · {deadlineInfo.label}
)}
{task.title}
{task.taskType &&
{task.taskType} }
{task.desc &&
{task.desc}
}
{task.attachments?.length > 0 && (
)}
{showReassign ? (
onAssign(task.id, e.target.value)}>
Zuweisen an…
{team.map((m) => (
{m.name}
))}
) : (
{member?.name || "—"}
)}
{stop?.city}
{task.due && (
{new Date(task.due + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}
)}
{(task.contactName || task.contactPhone) && (
task.contactPhone ? (
e.stopPropagation()}
>
{task.contactName || task.contactPhone}
) : (
{task.contactName}
)
)}
{task.broadcastId &&
{team.length === 2 ? "An beide verteilt" : "An alle verteilt"}}
{showBroadcast && (
onBroadcast(task)}
>
{team.length === 2 ? "Auch an Kollegen geben" : "An alle verteilen"}
)}
{canDelete && (
{confirmDelete ? Wirklich? : }
)}
);
}
function RouteMapModal({ stop, imageUrl, onClose, onSave }) {
const [uploading, setUploading] = useState(false);
const handleFile = async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try {
let dataUrl = await readFileAsDataUrl(file);
dataUrl = await compressImage(dataUrl, 1400, 0.8);
onSave(stop.id, dataUrl);
} finally {
setUploading(false);
}
};
return (
e.stopPropagation()}>
Anfahrt · {stop.city}
{imageUrl ? (
) : (
Für diese Station liegt noch kein Anfahrtsplan vor.
)}
{uploading ? "Wird hochgeladen…" : imageUrl ? "Bild ersetzen" : "Foto hochladen"}
);
}
function TourTimeline({ stops, tasks, routeMaps, onSaveRouteMap, onSelectStop }) {
const todayStr = new Date().toISOString().slice(0, 10);
const [mapStop, setMapStop] = useState(null);
return (
Tourplan
Breakin' Circus · Tournee 2026 · {stops.reduce((sum, s) => sum + (s.type === "show" ? Math.max(s.times.length, 1) : 0), 0)} Shows
Venue-Adressen vor Tourstart bitte prüfen · Hotels folgen.
{stops.map((s) => {
const isOff = s.type !== "show";
const du = daysUntil(s.date);
const isPast = du !== null && du < 0;
const isToday = s.date === todayStr;
const openTasks = tasks.filter((t) => t.stopId === s.id && t.status !== "erledigt").length;
const dateObj = new Date(s.date + "T00:00:00");
const hasRouteMap = !!routeMaps[s.id];
return (
onSelectStop(s.id)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelectStop(s.id); }}
role="button"
tabIndex={0}
>
{dateObj.toLocaleDateString("de-DE", { day: "2-digit" })}
{dateObj.toLocaleDateString("de-DE", { month: "short" })}
{dateObj.toLocaleDateString("de-DE", { weekday: "short" })}
{s.city}
{isToday && Heute }
{openTasks > 0 && {openTasks} offen }
{isOff ? (
Keine Show
) : (
{[s.arena, formatTimes(s.times)].filter(Boolean).join(" · ")}
)}
{s.venueAddress && (
{formatAddress(s.venueAddress)}
)}
{s.hotelAddress && (
{formatAddress(s.hotelAddress)}
)}
{s.specialEvent && (
{s.specialEvent}
)}
{!isOff && (
{ e.stopPropagation(); setMapStop(s); }}
>
{hasRouteMap ? "Anfahrtsplan" : "Anfahrt hinzufügen"}
)}
);
})}
{mapStop && (
setMapStop(null)}
onSave={(stopId, dataUrl) => { onSaveRouteMap(stopId, dataUrl); setMapStop((s) => (s ? { ...s } : s)); }}
/>
)}
);
}
function UrgencyPicker({ value, onChange, dueDate, onDueDateChange }) {
return (
Dringlichkeit
{PRIORITY_ORDER.map((key) => {
const p = PRIORITIES[key];
return (
onChange(key)}
>
{p.label}
({p.sub})
);
})}
{value === "anderer_tag" && (
onDueDateChange(e.target.value)}
/>
)}
);
}
function NewTaskPage({ onCreate, team, stops, currentUser, onDone }) {
const [title, setTitle] = useState("");
const [desc, setDesc] = useState("");
const [taskType, setTaskType] = useState(TASK_TYPES[0]);
const [priority, setPriority] = useState("normal");
const [due, setDue] = useState("");
const [contactName, setContactName] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [attachments, setAttachments] = useState([]);
const [saved, setSaved] = useState(false);
const [touched, setTouched] = useState(false);
const leader = team.find((m) => m.isLeader) || team[0];
const missingContact = !contactName.trim() || !contactPhone.trim();
const missingRequired = !title.trim() || !desc.trim() || missingContact || (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);
onCreate({
id: uid(),
title: title.trim(),
desc: desc.trim(),
assignee: leader?.id || "",
stopId: stop?.id || stops[0]?.id,
priority,
due: effectiveDue,
status: "offen",
taskType,
contactName: contactName.trim(),
contactPhone: contactPhone.trim(),
createdBy: currentUser?.id || null,
attachments,
createdAt: Date.now(),
});
setSaved(true);
setTimeout(() => {
setTitle(""); setDesc(""); setDue(""); setContactName(""); setContactPhone("");
setAttachments([]); setTaskType(TASK_TYPES[0]); setPriority("normal"); setSaved(false); setTouched(false);
if (onDone) onDone();
}, 900);
};
return (
);
}
function SettingsPage({ currentUser, notifState, onEnableNotif, onTestNotif, themePref, onSetTheme }) {
const isStandalone = typeof window !== "undefined" && (window.navigator.standalone || window.matchMedia?.("(display-mode: standalone)").matches);
return (
Einstellungen
Für {currentUser.name}
Farbschema
onSetTheme("system")}>
Gerätestandard
onSetTheme("light")}>
Hell
onSetTheme("dark")}>
Dunkel
Benachrichtigungen
{notifState === "granted" ? "Aktiviert – du erhältst Hinweise zu neuen Aufträgen." :
notifState === "denied" ? "In den iPhone-Einstellungen blockiert. Dort für diese Web-App wieder erlauben." :
"Noch nicht aktiviert."}
{notifState === "granted" ? : }
{notifState === "granted" ? "An" : "Aktivieren"}
{notifState === "granted" && (
Test-Mitteilung senden
)}
Auf dem iPhone-Homebildschirm ablegen
Diese Seite in Safari öffnen.
Unten auf das Teilen-Symbol tippen.
„Zum Home-Bildschirm" wählen.
App vom Homebildschirm starten und hier Mitteilungen aktivieren.
{!isStandalone && (
Hinweis: Mitteilungen funktionieren auf dem iPhone nur, wenn die Seite als Web-App über den Homebildschirm geöffnet wird.
)}
Geplante Mitteilungen
Neuer Auftrag für dich · sofort
Tages-Zusammenfassung · 08:00, 13:00, 18:00 Uhr
Automatische Zeitpläne und Push bei geschlossener App benötigen die Server-Anbindung (siehe Hinweis unten in der Fußzeile).
);
}
function LeaderDashboard({ team, tasks, stopById, onSelectTask }) {
const stats = useMemo(() => {
return team.map((m) => {
const mine = tasks.filter((t) => t.assignee === m.id);
const offen = mine.filter((t) => t.status === "offen").length;
const inArbeit = mine.filter((t) => t.status === "in_arbeit").length;
const done = mine.filter((t) => t.status === "erledigt").length;
const total = offen + inArbeit + done;
const donePct = total > 0 ? (done / total) * 100 : 0;
const workPct = total > 0 ? (inArbeit / total) * 100 : 0;
return { member: m, offen, inArbeit, done, total, donePct, workPct };
});
}, [team, tasks]);
const totalOffen = tasks.filter((t) => t.status === "offen").length;
const totalInArbeit = tasks.filter((t) => t.status === "in_arbeit").length;
const totalDone = tasks.filter((t) => t.status === "erledigt").length;
const unassignedOpen = tasks.filter((t) => t.status !== "erledigt" && !t.assignee).length;
const openTasksSorted = useMemo(() => {
return tasks
.filter((t) => t.status !== "erledigt")
.sort((a, b) =>
STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) ||
PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority) ||
a.createdAt - b.createdAt
);
}, [tasks]);
const memberById = useMemo(() => Object.fromEntries(team.map((m) => [m.id, m])), [team]);
return (
Team-Übersicht
Alle Aufträge auf einen Blick
{totalOffen}
Offen
{totalInArbeit}
In Arbeit
{totalDone}
Erledigt
{unassignedOpen}
Nicht zugewiesen
Pro Person
{stats.map(({ member, offen, inArbeit, done, donePct, workPct }) => (
{member.name.charAt(0)}
{member.name}
{member.role}
{offen} offen
{inArbeit} in Arbeit
{done} erledigt
))}
Alle offenen & laufenden Aufträge
{openTasksSorted.length === 0 ? (
Aktuell liegt nichts an.
) : (
{openTasksSorted.map((t) => (
onSelectTask(t)}>
{t.title}
{STATUSES[t.status]?.short} · {t.assignee ? memberById[t.assignee]?.name : "Nicht zugewiesen"} · {stopById[t.stopId]?.city}
))}
)}
);
}
function LocationPage({ currentUser, team }) {
const [locations, setLocations] = useState({});
const [sharing, setSharing] = useState(false);
const [geoError, setGeoError] = useState("");
const [myPos, setMyPos] = useState(null);
const watchIdRef = useRef(null);
const lastWriteRef = useRef(0);
// Eigene Freigabe-Präferenz laden (persönlich, nicht geteilt)
useEffect(() => {
(async () => {
try {
const r = await window.storage.get("el-share-location", false);
if (r?.value === "true") setSharing(true);
} catch (e) {}
})();
}, []);
// Fremde Standorte periodisch nachladen, solange die Seite offen ist
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const r = await window.storage.get("el-locations", true);
if (!cancelled && r?.value) setLocations(JSON.parse(r.value));
} catch (e) {}
};
load();
const iv = setInterval(load, 15000);
return () => { cancelled = true; clearInterval(iv); };
}, []);
const writeMyLocation = useCallback(async (coords) => {
setMyPos(coords);
const now = Date.now();
if (now - lastWriteRef.current < 20000) return; // höchstens alle 20s schreiben
lastWriteRef.current = now;
try {
const r = await window.storage.get("el-locations", true);
const current = r?.value ? JSON.parse(r.value) : {};
current[currentUser.id] = { lat: coords.latitude, lng: coords.longitude, ts: now };
await window.storage.set("el-locations", JSON.stringify(current), true);
setLocations(current);
} catch (e) {}
}, [currentUser.id]);
const startSharing = useCallback(() => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
setGeoError("Standortzugriff wird auf diesem Gerät nicht unterstützt.");
return;
}
setGeoError("");
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => writeMyLocation(pos.coords),
(err) => setGeoError(err.message === "User denied Geolocation" ? "Standortzugriff wurde verweigert. Bitte in den iPhone-Einstellungen für Safari erlauben." : "Standort konnte nicht ermittelt werden."),
{ enableHighAccuracy: false, maximumAge: 15000, timeout: 15000 }
);
}, [writeMyLocation]);
const stopSharing = useCallback(() => {
if (watchIdRef.current !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current);
watchIdRef.current = null;
}
}, []);
const toggleSharing = () => {
const next = !sharing;
setSharing(next);
window.storage.set("el-share-location", String(next), false).catch(() => {});
if (next) startSharing(); else stopSharing();
};
useEffect(() => {
if (sharing) startSharing();
return () => stopSharing();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = team
.filter((m) => m.id !== currentUser.id)
.map((m) => {
const loc = locations[m.id];
const dist = loc && myPos ? distanceKm(myPos.latitude, myPos.longitude, loc.lat, loc.lng) : null;
return { member: m, loc, dist };
});
return (
Standort
Live-Übersicht während der Tournee
Meinen Standort teilen
{sharing ? "Aktiv – das Team sieht deinen Standort, solange die App offen ist." : "Aus – dein Standort wird nicht geteilt."}
{sharing ? "An" : "Aus"}
{geoError &&
{geoError}
}
Team
{rows.map(({ member, loc, dist }) => (
{member.name.charAt(0)}
{member.name}
{member.role}
{loc ? `${relativeTime(loc.ts)}${dist !== null ? ` · ${dist.toFixed(1)} km entfernt` : ""}` : "Kein Standort geteilt"}
{loc && (
)}
))}
Standort-Freigabe funktioniert nur, solange die App bei der jeweiligen Person geöffnet ist – iOS erlaubt Web-Apps kein dauerhaftes Hintergrund-Tracking. Für eine Karte mit mehreren Positions-Pins wäre zusätzlich ein Kartendienst-API-Key (z. B. Google Maps) nötig.
);
}
function App() {
const { tasks, saveTasks, team, saveTeam, stops, saveStops, routeMaps, saveRouteMap, loaded } = useStorage();
const [currentUser, setCurrentUser] = useState(null); // session-only, resets on reload
const [page, setPage] = useState("tasks"); // tasks | new | tour | settings
const [menuOpen, setMenuOpen] = useState(false);
const [stopModalOpen, setStopModalOpen] = useState(false);
const [activeStopId, setActiveStopId] = useState(stops[0].id);
const [personFilter, setPersonFilter] = useState("alle");
const [bellOpen, setBellOpen] = useState(false);
const [onlyMine, setOnlyMine] = useState(false);
const [notifState, setNotifState] = useState(typeof Notification !== "undefined" ? Notification.permission : "unsupported");
const [themePref, setThemePref] = useState("system"); // system | light | dark
const [systemDark, setSystemDark] = useState(true);
const seenTaskIds = useRef(null);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
setSystemDark(mq.matches);
const handler = (e) => setSystemDark(e.matches);
mq.addEventListener ? mq.addEventListener("change", handler) : mq.addListener(handler);
return () => (mq.removeEventListener ? mq.removeEventListener("change", handler) : mq.removeListener(handler));
}, []);
useEffect(() => {
(async () => {
try {
const r = await window.storage.get("el-theme", false);
if (r?.value) setThemePref(r.value);
} catch (e) {}
})();
}, []);
const setTheme = useCallback((t) => {
setThemePref(t);
window.storage.set("el-theme", t, false).catch(() => {});
}, []);
const resolvedTheme = themePref === "system" ? (systemDark ? "dark" : "light") : themePref;
const stopById = useMemo(() => Object.fromEntries(stops.map((s) => [s.id, s])), [stops]);
const tourCounters = useMemo(() => computeTourCounters(stops), [stops]);
const todayInfo = useMemo(() => findStopForDate(todayStr(), stops), [stops]);
const memberById = useMemo(() => Object.fromEntries(team.map((m) => [m.id, m])), [team]);
const createStop = (stop) => saveStops([...stops, stop]);
const handleCreateMember = (member, andLogin) => {
const exists = team.some((m) => m.id === member.id);
const next = exists ? team.map((m) => (m.id === member.id ? member : m)) : [...team, member];
saveTeam(next);
if (andLogin) setCurrentUser(member);
};
const filtered = useMemo(() => {
return tasks
.filter((t) => t.stopId === activeStopId)
.filter((t) => personFilter === "alle" || (personFilter === "offen_zuweisung" ? !t.assignee : t.assignee === personFilter))
.filter((t) => !onlyMine || t.assignee === currentUser?.id)
.sort((a, b) => {
const order = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
if (order !== 0) return order;
return b.createdAt - a.createdAt;
});
}, [tasks, activeStopId, personFilter, onlyMine, currentUser]);
const notifications = useMemo(() => {
return tasks
.filter((t) => t.status !== "erledigt")
.map((t) => ({ ...t, du: daysUntil(t.due) }))
.filter((t) => t.du !== null && t.du <= 2)
.sort((a, b) => a.du - b.du);
}, [tasks]);
const myNotifications = notifications.filter((n) => n.assignee === currentUser?.id);
const toggleTask = (id) => {
saveTasks(tasks.map((t) => (t.id === id ? { ...t, status: nextStatus(t.status) } : t)));
};
const deleteTask = (id) => saveTasks(tasks.filter((t) => t.id !== id));
const createTask = (t) => {
saveTasks([t, ...tasks]);
if (t.stopId) setActiveStopId(t.stopId);
};
const assignTask = (id, memberId) => saveTasks(tasks.map((t) => (t.id === id ? { ...t, assignee: memberId } : t)));
// Verteilt einen bereits existierenden Auftrag an alle übrigen Teammitglieder –
// jede Person bekommt eine eigene Kopie, die Original-Zuweisung bleibt bestehen.
const broadcastTask = (task) => {
const broadcastId = task.broadcastId || uid();
const others = team.filter((m) => m.id !== task.assignee);
const copies = others.map((m) => ({
...task,
id: uid(),
assignee: m.id,
status: "offen",
broadcastId,
createdAt: Date.now(),
}));
const updatedTasks = tasks.map((t) => (t.id === task.id ? { ...t, broadcastId } : t));
saveTasks([...copies, ...updatedTasks]);
};
const openCount = tasks.filter((t) => t.stopId === activeStopId && t.status !== "erledigt").length;
const showNotification = useCallback((title, body) => {
try {
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
new Notification(title, { body, icon: APP_ICON, badge: APP_ICON });
}
} catch (e) {}
}, []);
const enableNotif = useCallback(async () => {
if (typeof Notification === "undefined") return;
try {
const res = await Notification.requestPermission();
setNotifState(res);
if (res === "granted") showNotification("Mitteilungen aktiv", "Du erhältst jetzt Hinweise zu neuen Aufträgen.");
} catch (e) {}
}, [showNotification]);
const testNotif = useCallback(() => {
showNotification("Testmitteilung", "So sieht eine Mitteilung vom Breakin' Circus Fahrteam aus.");
}, [showNotification]);
// Neue, mir zugewiesene Aufträge erkennen -> lokale Mitteilung (funktioniert, solange die Web-App aktiv/im Hintergrund ist)
useEffect(() => {
if (!currentUser) return;
const mine = tasks.filter((t) => t.assignee === currentUser.id).map((t) => t.id);
if (seenTaskIds.current === null) {
seenTaskIds.current = new Set(mine);
return;
}
const fresh = mine.filter((id) => !seenTaskIds.current.has(id));
if (fresh.length > 0 && notifState === "granted") {
const t = tasks.find((x) => x.id === fresh[0]);
showNotification(
fresh.length === 1 ? "Neuer Auftrag für dich" : `${fresh.length} neue Aufträge für dich`,
t ? t.title : "Öffne die App, um Details zu sehen."
);
}
seenTaskIds.current = new Set(mine);
}, [tasks, currentUser, notifState, showNotification]);
// Session zurücksetzen bei Abmeldung
useEffect(() => {
if (!currentUser) seenTaskIds.current = null;
}, [currentUser]);
const goto = (p) => { setPage(p); setMenuOpen(false); };
const myOpenToday = tasks.filter((t) => t.assignee === currentUser?.id && t.status !== "erledigt").length;
const myTasksList = useMemo(() => {
if (!currentUser) return [];
return tasks
.filter((t) => t.assignee === currentUser.id && t.status !== "erledigt")
.sort((a, b) =>
STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) ||
PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority) ||
a.createdAt - b.createdAt
);
}, [tasks, currentUser]);
if (!loaded) {
return (
);
}
if (!currentUser) {
return (
);
}
return (
setMenuOpen(true)} aria-label="Menü">
BREAKIN’ CIRCUS Tour 2026
setBellOpen((v) => !v)}>
{myNotifications.length > 0 && {myNotifications.length} }
{bellOpen && (
Für dich bald fällig
{myNotifications.length === 0 &&
Nichts Dringendes für dich.
}
{myNotifications.map((n) => (
{ setActiveStopId(n.stopId); setBellOpen(false); setPage("tasks"); }}>
{n.title}
{stopById[n.stopId]?.city} · {n.du === 0 ? "heute" : n.du < 0 ? `${Math.abs(n.du)} Tag(e) überfällig` : `in ${n.du} Tag(en)`}
))}
)}
{menuOpen && (
setMenuOpen(false)}>
e.stopPropagation()}>
{currentUser.name.charAt(0)}
{currentUser.name}
{currentUser.role}
setMenuOpen(false)}>
goto("tasks")}>
Startseite
{myOpenToday > 0 && {myOpenToday} }
goto("new")}>
Neuer Auftrag
goto("tour")}>
Tourplan
{currentUser.isLeader && (
goto("leader")}>
Team-Übersicht
)}
goto("settings")}>
Einstellungen
{notifState !== "granted" && }
{ setMenuOpen(false); setCurrentUser(null); }}>
Abmelden
)}
{page === "tasks" && (
<>
{tourCounters && !tourCounters.started && (
setActiveStopId(tourCounters.firstShow.id)}>
Tour startet in {tourCounters.daysUntilStart} Tag{tourCounters.daysUntilStart === 1 ? "" : "en"}
{tourCounters.firstShow.city} · {new Date(tourCounters.firstShow.date + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short", year: "numeric" })}
)}
{tourCounters && tourCounters.started && (
setActiveStopId(tourCounters.showStop.id)}>
Show {tourCounters.showLabel}/{tourCounters.showTotal}
{tourCounters.isToday ? "heute" : `nächste · ${new Date(tourCounters.showStop.date + "T00:00:00").toLocaleDateString("de-DE", { day: "2-digit", month: "short" })}`}
setActiveStopId(tourCounters.showStop.id)}>
Stadt {tourCounters.cityIndex}/{tourCounters.cityTotal}
{tourCounters.showStop.city}
)}
{todayInfo && (todayInfo.venueAddress || todayInfo.hotelAddress) && (
setActiveStopId(todayInfo.id)}>
{todayInfo.date === todayStr() ? "Heute" : "Nächste Station"} · {todayInfo.city}
{todayInfo.venueAddress && (
Venue
{formatAddress(todayInfo.venueAddress)}
)}
{todayInfo.hotelAddress && (
Hotel
{formatAddress(todayInfo.hotelAddress)}
)}
)}
Meine Aufgaben
{myTasksList.length > 0 && {myTasksList.length} }
{myTasksList.length === 0 ? (
Aktuell liegt nichts bei dir an.
) : (
{myTasksList.slice(0, 6).map((t) => (
setActiveStopId(t.stopId)}>
{t.title}
{STATUSES[t.status]?.short} · {stopById[t.stopId]?.city}
))}
{myTasksList.length > 6 && (
setOnlyMine(true)}>
+{myTasksList.length - 6} weitere – alle unten anzeigen
)}
)}
Alle Aufträge
setStopModalOpen(true)} />
{stopById[activeStopId]?.city}
{stopById[activeStopId]?.type !== "show"
? "Kein Auftritt geplant"
: [stopById[activeStopId]?.arena, formatTimes(stopById[activeStopId]?.times)].filter(Boolean).join(" · ")}
{" · "}{openCount} offen
setOnlyMine((v) => !v)}>
Nur meine
setPersonFilter(e.target.value)}>
Alle Personen
Nicht zugewiesen
{team.map((m) => (
{m.name}
))}
{filtered.length === 0 ? (
Für {stopById[activeStopId]?.city} liegt noch nichts vor.
setPage("new")}>Ersten Auftrag anlegen
) : (
{filtered.map((t) => (
))}
)}
>
)}
{page === "new" && (
setPage("tasks")}
/>
)}
{page === "tour" && (
{ setActiveStopId(id); setPage("tasks"); }}
/>
)}
{page === "leader" && currentUser.isLeader && (
{ setActiveStopId(t.stopId); setPage("tasks"); }}
/>
)}
{page === "settings" && (
)}
setStopModalOpen(false)} onCreate={createStop} />
Geteilte Daten für alle mit diesem Link · Echte Push bei geschlossener App (neuer Auftrag + 3× tägliche Zusammenfassung) benötigt eine Server-Anbindung, die separat eingerichtet werden muss.
);
}
const __root = createRoot(document.getElementById("root"));
__root.render( );