Add Radeln ohne Alter ride-coordination demo (Django + HTMX + Leaflet)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
steffen1971 2026-06-20 21:20:17 +02:00
commit 5d967c68b5
40 changed files with 2040 additions and 0 deletions

View file

@ -0,0 +1,42 @@
<article class="card ride-card">
<div class="ride-head">
<span class="badge" style="--c: {{ ride.status_color }}">{{ ride.get_status_display }}</span>
<time datetime="{{ ride.scheduled_at|date:'c' }}">{{ ride.scheduled_at|date:"D, d.m. H:i" }}</time>
</div>
<h3 class="ride-title"><a href="{% url 'ride_detail' ride.pk %}">{{ ride.fahrgast.name }}</a></h3>
<p class="ride-route">
<span class="pt pt-from">{{ ride.pickup_label }}</span>
<span class="pt pt-to">{{ ride.dest_label }}</span>
</p>
{% if ride.assigned_pilot %}
<p class="muted small">Pilot: {{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}</p>
{% endif %}
<div class="ride-actions">
{% if can_claim and ride.can_be_claimed %}
<form hx-post="{% url 'ride_claim' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_claim' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn btn-primary">Übernehmen</button>
</form>
{% endif %}
{% if can_finish and ride.can_be_finished %}
<form hx-post="{% url 'ride_done' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_done' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn">Erledigt</button>
</form>
{% endif %}
{% if can_cancel and ride.can_be_cancelled %}
<form hx-post="{% url 'ride_cancel' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_cancel' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn btn-ghost">Absagen</button>
</form>
{% endif %}
<a class="btn btn-ghost" href="{% url 'ride_detail' ride.pk %}">Details</a>
</div>
</article>

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Einzugsgebiet · Radeln ohne Alter{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<h1>Unser Einzugsgebiet</h1>
<p class="lede">Wir holen Sie in Malstatt, St. Johann und Alt-Saarbrücken ab — kostenlos und ehrenamtlich.
„Für das Recht auf Wind in den Haaren."</p>
<div id="map" class="full-map"></div>
<p class="muted small">Diese Seite ist öffentlich und enthält keine personenbezogenen Daten.</p>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map").setView([49.238, 6.980], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
// Grobes Einzugsgebiet (Malstatt / St. Johann / Alt-Saarbrücken)
const area = [
[49.262, 6.945], [49.258, 6.985], [49.240, 7.012],
[49.222, 6.995], [49.218, 6.958], [49.235, 6.938],
];
L.polygon(area, { color: "#2f7d57", weight: 2, fillColor: "#2f7d57", fillOpacity: 0.12 }).addTo(map);
</script>
{% endblock %}

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Übersicht · Radeln ohne Alter{% endblock %}
{% block content %}
{% if role == 'coordinator' %}
<div class="section-head">
<h1>Übersicht</h1>
<a class="btn btn-primary" href="{% url 'ride_create' %}">+ Fahrt anlegen</a>
</div>
<div class="stat-chips">
{% for s in status_counts %}
<span class="chip"><span class="role-dot" style="background:{{ s.color }}"></span>{{ s.label }} <strong>{{ s.n }}</strong></span>
{% endfor %}
</div>
<div class="card-grid">
{% for ride in rides %}
{% include "rides/_ride_card.html" with ride=ride can_finish=True can_cancel=True %}
{% empty %}
<p class="empty">Keine anstehenden Fahrten.</p>
{% endfor %}
</div>
{% elif role == 'pilot' %}
<h1>Hallo {{ user.first_name|default:user.username }}</h1>
<section class="block">
<h2>Offene Fahrten</h2>
<div class="card-grid">
{% for ride in open_rides %}
{% include "rides/_ride_card.html" with ride=ride can_claim=True %}
{% empty %}
<p class="empty">Gerade keine offenen Fahrten.</p>
{% endfor %}
</div>
</section>
<section class="block">
<h2>Meine Fahrten</h2>
<div class="card-grid">
{% for ride in my_rides %}
{% include "rides/_ride_card.html" with ride=ride can_finish=True %}
{% empty %}
<p class="empty">Noch keine Fahrt übernommen.</p>
{% endfor %}
</div>
</section>
<section class="block card avail">
<h2>Meine Verfügbarkeit</h2>
<ul class="avail-list">
{% for a in availabilities %}
<li>{{ a.start|date:"D, d.m. H:i" }} {{ a.end|date:"H:i" }}</li>
{% empty %}
<li class="muted">Noch nichts eingetragen.</li>
{% endfor %}
</ul>
<form method="post" action="{% url 'availability_add' %}" class="avail-form">{% csrf_token %}
<label>Von {{ availability_form.start }}</label>
<label>Bis {{ availability_form.end }}</label>
<button class="btn">Hinzufügen</button>
</form>
</section>
{% else %}
<div class="section-head">
<h1>Meine Anfragen</h1>
<a class="btn btn-primary" href="{% url 'ride_create' %}">+ Fahrt anfragen</a>
</div>
<div class="card-grid">
{% for ride in my_requests %}
{% include "rides/_ride_card.html" with ride=ride %}
{% empty %}
<p class="empty">Noch keine Anfragen gestellt.</p>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<section class="hero">
<h1>Fahrten koordinieren — ohne WhatsApp-Chaos</h1>
<p class="lede">Verfügbarkeiten, Anfragen und Touren an einem Ort. Wähle eine Rolle, um die Demo auszuprobieren.</p>
<div class="role-grid">
{% for u in demo_users %}
<form method="post" action="{% url 'demo_login' u.id %}">{% csrf_token %}
<button class="role-card">
<span class="role-dot role-{{ u.profile.role }}"></span>
<strong>{{ u.get_full_name|default:u.username }}</strong>
<span class="muted">{{ u.profile.get_role_display }}</span>
</button>
</form>
{% empty %}
<p class="empty">Noch keine Demo-Daten. Bitte zuerst <code>python manage.py seed_demo</code> ausführen.</p>
{% endfor %}
</div>
<p class="hint">Demo-Login ohne Passwort — nur in der lokalen Vorschau aktiv.</p>
</section>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Karte · Radeln ohne Alter{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<h1>Fahrten-Karte</h1>
<p class="muted">Abholorte der anstehenden und erledigten Fahrten. Farbe = Status.</p>
<div id="map" class="full-map"></div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map").setView([49.240, 6.985], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
fetch("{% url 'rides_geojson' %}")
.then((r) => r.json())
.then((data) => {
const layer = L.geoJSON(data, {
pointToLayer: (f, latlng) =>
L.circleMarker(latlng, {
radius: 9, weight: 2, color: "#fff",
fillColor: f.properties.color, fillOpacity: 0.95,
}),
onEachFeature: (f, l) =>
l.bindPopup(
`<strong>${f.properties.label}</strong><br>${f.properties.status} · ${f.properties.when}` +
`<br><a href="${f.properties.url}">Details →</a>`
),
}).addTo(map);
if (data.features.length) map.fitBounds(layer.getBounds().pad(0.2));
});
</script>
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{{ ride.fahrgast.name }} · Fahrt{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<div class="detail-grid">
<div class="card">
<span class="badge" style="--c: {{ ride.status_color }}">{{ ride.get_status_display }}</span>
<h1>{{ ride.fahrgast.name }}</h1>
<dl class="meta">
<dt>Wann</dt><dd>{{ ride.scheduled_at|date:"l, d. F Y, H:i" }} Uhr</dd>
<dt>Abholung</dt><dd>{{ ride.pickup_label }}</dd>
<dt>Ziel</dt><dd>{{ ride.dest_label }}</dd>
{% if ride.assigned_pilot %}<dt>Pilot</dt><dd>{{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}</dd>{% endif %}
{% if ride.fahrgast.einrichtung %}<dt>Einrichtung</dt><dd>{{ ride.fahrgast.einrichtung.name }}</dd>{% endif %}
</dl>
{% if show_notes %}
{% if ride.fahrgast.mobility_notes %}
<div class="notes"><h3>Hinweise zum Fahrgast</h3><p>{{ ride.fahrgast.mobility_notes }}</p></div>
{% endif %}
{% if ride.note %}
<div class="notes"><h3>Notiz zur Fahrt</h3><p>{{ ride.note }}</p></div>
{% endif %}
{% else %}
<p class="muted small notes-locked">🔒 Pflege-Hinweise sind nur für die zugeteilte Pilot:in und die Koordination sichtbar.</p>
{% endif %}
</div>
<div class="card map-card">
<div id="map" class="detail-map"></div>
</div>
</div>
{{ ride.route_geojson|json_script:"route-data" }}
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const pickup = [{{ ride.pickup_lat }}, {{ ride.pickup_lng }}];
const dest = [{{ ride.dest_lat }}, {{ ride.dest_lng }}];
const route = JSON.parse(document.getElementById("route-data").textContent);
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
L.marker(pickup).addTo(map).bindPopup("Abholung");
L.marker(dest).addTo(map).bindPopup("Ziel");
let line;
if (route) {
line = L.geoJSON(route, { style: { color: "#2f7d57", weight: 5 } }).addTo(map);
} else {
line = L.polyline([pickup, dest], { color: "#2f7d57", weight: 4, dashArray: "6 8" }).addTo(map);
}
map.fitBounds(line.getBounds().pad(0.3));
</script>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Fahrt anlegen{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<h1>Fahrt anlegen</h1>
<form method="post" class="form card">{% csrf_token %}
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
<label>Fahrgast {{ form.fahrgast }}{{ form.fahrgast.errors }}</label>
<label>Termin {{ form.scheduled_at }}{{ form.scheduled_at.errors }}</label>
<div class="two-col">
<label>Abholort {{ form.pickup_label }}{{ form.pickup_label.errors }}</label>
<label>Ziel {{ form.dest_label }}{{ form.dest_label.errors }}</label>
</div>
<label>Notiz {{ form.note }}</label>
{{ form.pickup_lat }}{{ form.pickup_lng }}{{ form.dest_lat }}{{ form.dest_lng }}
<p class="muted small">Marker auf der Karte ziehen: <span class="dot-inline" style="background:#2f8f5b"></span> Abholung,
<span class="dot-inline" style="background:#d14b4b"></span> Ziel.</p>
<div id="pickmap" class="form-map"></div>
<button class="btn btn-primary">Fahrt speichern</button>
</form>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const $ = (id) => document.getElementById(id);
// Voreinstellung: Malstatt (Abholung) → Staden (Ziel)
const start = { lat: 49.2517, lng: 6.9590 };
const end = { lat: 49.2326, lng: 6.9974 };
const sync = (point, latId, lngId) => { $(latId).value = point.lat.toFixed(6); $(lngId).value = point.lng.toFixed(6); };
sync(start, "id_pickup_lat", "id_pickup_lng");
sync(end, "id_dest_lat", "id_dest_lng");
const map = L.map("pickmap").setView([49.240, 6.978], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
const greenIcon = L.divIcon({ className: "pin pin-green", html: "", iconSize: [18, 18] });
const redIcon = L.divIcon({ className: "pin pin-red", html: "", iconSize: [18, 18] });
const a = L.marker([start.lat, start.lng], { draggable: true, icon: greenIcon }).addTo(map);
const b = L.marker([end.lat, end.lng], { draggable: true, icon: redIcon }).addTo(map);
a.on("dragend", () => sync(a.getLatLng(), "id_pickup_lat", "id_pickup_lng"));
b.on("dragend", () => sync(b.getLatLng(), "id_dest_lat", "id_dest_lng"));
</script>
{% endblock %}

View file

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Statistik · Radeln ohne Alter{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<h1>Statistik</h1>
<div class="big-number card">
<strong>{{ total_done }}</strong>
<span class="muted">erledigte Fahrten insgesamt</span>
</div>
<section class="block">
<h2>Nach Status</h2>
<div class="stat-chips">
{% for s in status_counts %}
<span class="chip"><span class="role-dot" style="background:{{ s.color }}"></span>{{ s.label }} <strong>{{ s.n }}</strong></span>
{% endfor %}
</div>
</section>
<section class="block">
<h2>Erledigte Fahrten je Pilot:in</h2>
<table class="table">
<thead><tr><th>Pilot:in</th><th>Fahrten</th></tr></thead>
<tbody>
{% for row in per_pilot %}
<tr>
<td>{{ row.assigned_pilot__first_name|default:row.assigned_pilot__username|default:"—" }}</td>
<td>{{ row.n }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="muted">Noch keine erledigten Fahrten.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}