Philipp Hofer be4ec4cbdc
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
fix tooltips in combination w/ overflow tables
2025-04-13 23:08:45 +02:00

574 lines
19 KiB
Rust

use crate::{
admin::station::Station,
er, err,
models::rating::{Rating, TeamsAtStationLocation},
partials::page,
suc, succ, AppState,
};
use axum::{
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_sessions::Session;
#[derive(Deserialize)]
struct CreateForm {
name: String,
}
async fn create(
State(db): State<Arc<SqlitePool>>,
session: Session,
Form(form): Form<CreateForm>,
) -> impl IntoResponse {
match Station::create(&db, &form.name).await {
Ok(()) => suc!(session, t!("station_create_succ", name = form.name)),
Err(e) => er!(
session,
t!(
"station_create_err_duplicate_name",
name = form.name,
err = e
)
),
}
Redirect::to("/admin/station")
}
async fn delete(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
er!(session, t!("station_delete_err_nonexisting", id = id));
return Redirect::to("/admin/station");
};
match station.delete(&db).await {
Ok(()) => suc!(session, t!("station_delete_succ", name = station.name)),
Err(e) => er!(
session,
t!(
"station_delete_err_already_used",
name = station.name,
err = e
)
),
}
Redirect::to("/admin/station")
}
async fn view(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht geöffnet werden, da sie nicht existiert"
);
return Err(Redirect::to("/admin/station"));
};
let ratings = Rating::for_station(&db, &station).await;
// maybe switch to maud-display impl of station
let content = html! {
h1 {
a href="/admin/station" { "↩️" }
"Station " (station.name)
}
article {
details {
summary { "Stationsname bearbeiten ✏️" }
form action=(format!("/admin/station/{}/name", station.id)) method="post" {
input type="text" name="name" value=(station.name) required;
input type="submit" value="Speichern";
}
}
}
table {
tbody {
tr {
th scope="row" { "Notizen" };
td {
@match station.notes {
Some(ref notes) => {
(notes)
details {
summary { "✏️" }
form action=(format!("/admin/station/{}/notes", station.id)) method="post" {
textarea name="notes" required rows="10" { (notes) };
input type="submit" value="Speichern";
}
}
},
None => details {
summary { "Neue Notiz hinzufügen" }
form action=(format!("/admin/station/{}/notes", station.id)) method="post" {
textarea name="notes" required rows="10" {};
input type="submit" value="Speichern";
}
}
}
}
}
tr {
th scope="row" { "Stations-Link" };
td {
a href=(format!("/s/{}/{}", station.id, station.pw)) {
"Login-Link"
}
article class="warning" {
(format!("Diesen Link nur Betreuern der Station {} geben! Mit diesem Link erhält man die Berechtigung, Teams zu bewerten.", station.name))
}
}
}
tr {
th scope="row" { "Anzahl Stationsbetreuer" };
td {
@match station.amount_people {
Some(amount) => (amount),
None => "?",
}
details {
summary { "✏️" }
form action=(format!("/admin/station/{}/amount-people", station.id)) method="post" {
input type="number" name="amount_people" min="0" max="10";
input type="submit" value="Speichern";
}
button class="error" {
a href=(format!("/admin/station/{}/amount-people-reset", station.id)) {
em data-tooltip="Ich weiß noch nicht wv. Personen benötigt werden." {
"?"
}
}
}
}
}
}
tr {
th scope="row" { "Letzter Zugriff eines Stationsbetreuers" };
td {
@match station.local_last_login() {
Some(last_login) => (last_login),
None => "noch nicht eingeloggt :-(",
}
}
}
}
}
@if !ratings.is_empty() {
h2 { "Bewertungen" }
div class="overflow-auto" {
table {
thead {
tr {
th { "Team" }
th { "Punkte" }
th { "Notizen" }
th {
em data-placement="bottom" data-tooltip="Angekommen" {
"👋"
}
}
th {
em data-placement="bottom" data-tooltip="Begonnen" {
"🎬"
}
}
th {
em data-placement="bottom" data-tooltip="Gegangen" {
"🚶‍♂️"
}
}
}
}
tbody {
@for rating in ratings {
tr {
td {
a href=(format!("/admin/team/{}", rating.team_id)) {
(rating.team(&db).await.name)
}
}
td {
@if let Some(points) = rating.points {
(points)
}
}
td {
@if let Some(ref notes) = rating.notes{
(notes)
}
}
td {
(rating.local_time_arrived_at())
}
td {
(rating.local_time_doing())
}
td {
(rating.local_time_left())
}
}
}
}
}
}
}
@if let (Some(_), Some(_)) = (station.lat, station.lng) {
article {
"Um einen neuen Standort zu wählen, auf einen Punkt in der Karte klicken"
}
} @else{
article {
"Um einen Standort zu wählen, auf einen Punkt in der Karte klicken"
}
}
@if station.lat.is_some() && station.lng.is_some() {
a href=(format!("/admin/station/{}/location-clear", station.id))
onclick="return confirm('Bist du sicher, dass du den Standort der Station löschen willst?');"{
"Standort löschen"
}
}
form action=(format!("/admin/station/{}/location", station.id)) method="post" {
input type="hidden" name="lat" id="lat";
input type="hidden" name="lng" id="lng";
input type="submit" value="Neuen Standort speichern" style="display: None;" id="location-submit";
}
div id="map" style="height: 500px" {}
script {
r#"
const map = L.map('map').setView([48.511445, 14.505301], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Variable to store the current marker
let currentMarker = null;
map.on('click', function(e) {
const lat = e.latlng.lat;
const lng = e.latlng.lng;
// Update the coordinates display
if (currentMarker !== null) {
map.removeLayer(currentMarker);
}
const myIcon = L.icon({
iconUrl: '/marker.png',
iconAnchor: [12, 41]
});
document.getElementById('lat').value = lat;
document.getElementById('lng').value = lng;
document.getElementById('location-submit').style.display = 'block';
document.getElementById('location-submit').scrollIntoView();
currentMarker = L.marker([lat, lng], {icon: myIcon}).addTo(map);
});
"#
}
@if let (Some(lat), Some(lng)) = (station.lat, station.lng) {
script { (format!("
const lat = {lat};
const lng = {lng};
const myIcon = L.icon({{
iconUrl: '/marker.png',
iconAnchor: [12, 41]
}});
currentMarker = L.marker([lat, lng], {{icon: myIcon}}).addTo(map);
map.setView([lat, lng], 14);
"));
}
}
};
Ok(page(content, session, true).await)
}
#[derive(Deserialize)]
struct UpdateNameForm {
name: String,
}
async fn update_name(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_name(&db, &form.name).await;
succ!(
session,
"Station '{}' heißt ab sofort '{}'.",
station.name,
form.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
#[derive(Deserialize)]
struct UpdateNotesForm {
notes: String,
}
async fn update_notes(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNotesForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_notes(&db, &form.notes).await;
succ!(
session,
"Notizen für die Station '{}' wurden erfolgreich bearbeitet!",
station.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
#[derive(Deserialize)]
struct UpdateAmountPeopleForm {
amount_people: i64,
}
async fn update_amount_people(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateAmountPeopleForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_amount_people(&db, form.amount_people).await;
succ!(
session,
"Anzahl an Betreuer für die Station '{}' wurden erfolgreich bearbeitet!",
station.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
async fn update_amount_people_reset(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_amount_people_reset(&db).await;
succ!(
session,
"Anzahl an Betreuer für die Station '{}' wurden erfolgreich bearbeitet!",
station.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
#[derive(Deserialize)]
struct UpdateLocationForm {
lat: f64,
lng: f64,
}
async fn update_location(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateLocationForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_location(&db, form.lat, form.lng).await;
succ!(
session,
"Standort für die Station '{}' wurden erfolgreich bearbeitet!",
station.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
async fn update_location_clear(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
err!(
session,
"Station mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert"
);
return Redirect::to("/admin/station");
};
station.update_location_clear(&db).await;
succ!(
session,
"Standort für die Station '{}' wurden erfolgreich gelöscht!",
station.name
);
Redirect::to(&format!("/admin/station/{id}"))
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let stations = Station::all(&db).await;
let content = html! {
h1 {
a href="/admin" { "↩️" }
(t!("stations"))
}
article {
em {
(t!("stations"))
" "
}
(t!("stations_expl_without_first_word"))
}
table {
thead {
tr {
th { "Station" }
th { "Fortschritt" }
th { "" }
}
}
tbody {
@for station in &stations {
@let status = TeamsAtStationLocation::for_station(&db, station).await;
tr {
td {
@if station.ready {
em data-tooltip="Station bereit!" {
small { "🟢 " }
}
}
@if station.routes(&db).await.is_empty() {
em data-tooltip=(t!("station_warning_not_assigned_route")) {
"⚠️ "
}
}
a href=(format!("/admin/station/{}", station.id)){
(station.name)
}
}
td {
em data-tooltip=(format!("{}/{} Teams (davon {} wartend + {} aktiv)", status.total_teams-status.not_yet_here.len() as i64, status.total_teams, status.waiting.len(), status.doing.len())) {
progress value=(status.total_teams-status.not_yet_here.len() as i64) max=(status.total_teams) {}
}
}
td {
a href=(format!("/admin/station/{}/delete", station.id))
onclick=(format!("return confirm('{}');", t!("station_confirm_deletion"))) {
"🗑️"
}
}
}
}
}
}
@if stations.is_empty() {
article class="warning" {
(t!("station_hint_create_first"))
}
}
h2 { (t!("station_new")) }
form action="/admin/station" method="post" {
fieldset role="group" {
input type="text" name="name" placeholder=(t!("station_name")) required;
input type="submit" value=(t!("station_new"));
}
}
};
page(content, session, false).await
}
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/", post(create))
.route("/{id}", get(view))
.route("/{id}/delete", get(delete))
.route("/{id}/name", post(update_name))
.route("/{id}/notes", post(update_notes))
.route("/{id}/amount-people", post(update_amount_people))
.route("/{id}/amount-people-reset", get(update_amount_people_reset))
.route("/{id}/location", post(update_location))
.route("/{id}/location-clear", get(update_location_clear))
}