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:
commit
5d967c68b5
40 changed files with 2040 additions and 0 deletions
42
rides/templates/rides/_ride_card.html
Normal file
42
rides/templates/rides/_ride_card.html
Normal 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>
|
||||
29
rides/templates/rides/coverage.html
Normal file
29
rides/templates/rides/coverage.html
Normal 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 %}
|
||||
78
rides/templates/rides/dashboard.html
Normal file
78
rides/templates/rides/dashboard.html
Normal 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 %}
|
||||
23
rides/templates/rides/home.html
Normal file
23
rides/templates/rides/home.html
Normal 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 %}
|
||||
38
rides/templates/rides/map.html
Normal file
38
rides/templates/rides/map.html
Normal 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 %}
|
||||
64
rides/templates/rides/ride_detail.html
Normal file
64
rides/templates/rides/ride_detail.html
Normal 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 %}
|
||||
58
rides/templates/rides/ride_form.html
Normal file
58
rides/templates/rides/ride_form.html
Normal 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 %}
|
||||
37
rides/templates/rides/stats.html
Normal file
37
rides/templates/rides/stats.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue