From 51b2b1a98c79ea21f2c9c37aefe20d2eca8e1fe4 Mon Sep 17 00:00:00 2001 From: Priec Date: Sat, 16 May 2026 16:06:15 +0200 Subject: [PATCH] admin features --- ht_booking/assets/i18n/en/main.ftl | 4 + ht_booking/assets/i18n/sk/main.ftl | 4 + .../assets/views/admin/booking_form.html | 47 ++++++++ ht_booking/src/controllers/admin.rs | 101 +++++++++++++++--- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/ht_booking/assets/i18n/en/main.ftl b/ht_booking/assets/i18n/en/main.ftl index 27ec4e4..fa93f49 100644 --- a/ht_booking/assets/i18n/en/main.ftl +++ b/ht_booking/assets/i18n/en/main.ftl @@ -53,3 +53,7 @@ settings = Settings settings-language = Language settings-theme = Theme view-details = Details +hour-from = From +hour-to = Until +repeat-weeks = Repeat for (weeks) +repeat-hint = 1 books a single week. A higher number repeats the same hours every following week. diff --git a/ht_booking/assets/i18n/sk/main.ftl b/ht_booking/assets/i18n/sk/main.ftl index d888fff..762d503 100644 --- a/ht_booking/assets/i18n/sk/main.ftl +++ b/ht_booking/assets/i18n/sk/main.ftl @@ -53,3 +53,7 @@ settings = Nastavenia settings-language = Jazyk settings-theme = Téma view-details = Detaily +hour-from = Od +hour-to = Do +repeat-weeks = Opakovať (počet týždňov) +repeat-hint = 1 rezervuje jeden týždeň. Vyššie číslo opakuje rovnaké hodiny každý ďalší týždeň. diff --git a/ht_booking/assets/views/admin/booking_form.html b/ht_booking/assets/views/admin/booking_form.html index 88064fc..579f2d4 100644 --- a/ht_booking/assets/views/admin/booking_form.html +++ b/ht_booking/assets/views/admin/booking_form.html @@ -24,6 +24,34 @@ + {% if mode == "new" %} +
+
+ + +
+
+ + +
+
+
+ + + +
+ {% else %}
+ {% endif %}
{% endblock content %} + +{% block js %} +{% if mode == "new" %} + +{% endif %} +{% endblock js %} diff --git a/ht_booking/src/controllers/admin.rs b/ht_booking/src/controllers/admin.rs index 70a6056..464db06 100644 --- a/ht_booking/src/controllers/admin.rs +++ b/ht_booking/src/controllers/admin.rs @@ -256,6 +256,15 @@ fn hour_options() -> Vec { .collect() } +/// End-of-block hour options (07:00‥22:00) for the "until" select. A booking +/// covers the hours in `[start, end)`, so the end runs one slot past the last +/// bookable hour. +fn end_hour_options() -> Vec { + (FIRST_HOUR + 1..=LAST_HOUR + 1) + .map(|h| data!({ "v": h, "label": format!("{h:02}:00") })) + .collect() +} + #[derive(Debug, Deserialize)] pub struct NewBookingQuery { pub court: Option, @@ -286,6 +295,9 @@ pub async fn booking_new( .and_then(|c| c.name.clone()) .unwrap_or_else(|| format!("Court {court_id}")); + let hour_start = q.hour.unwrap_or(FIRST_HOUR).clamp(FIRST_HOUR, LAST_HOUR); + let hour_end = (hour_start + 1).min(LAST_HOUR + 1); + format::render().view( &v, "admin/booking_form.html", @@ -298,18 +310,22 @@ pub async fn booking_new( "court_id": court_id, "court_name": court_name, "date": q.date.unwrap_or_default(), - "hour": q.hour.unwrap_or(FIRST_HOUR), + "hour_start": hour_start, + "hour_end": hour_end, + "repeat_weeks": 1, "color": "#3b82f6", "name": "", "title": "", "contact": "", "note": "", "hours": hour_options(), + "hours_end": end_hour_options(), "booking_id": 0, }), ) } +/// Form fields for editing a single existing booking. #[derive(Debug, Deserialize)] pub struct BookingForm { pub court_id: i32, @@ -322,31 +338,86 @@ pub struct BookingForm { pub note: Option, } +/// Form fields for creating bookings. Unlike editing, creation books a range +/// of hours (`[hour_start, hour_end)`) and can repeat the block weekly. +#[derive(Debug, Deserialize)] +pub struct BookingCreateForm { + pub court_id: i32, + pub date: String, + pub hour_start: i32, + pub hour_end: i32, + pub repeat_weeks: Option, + pub color: String, + pub name: String, + pub title: Option, + pub contact: Option, + pub note: Option, +} + fn parse_date(s: &str) -> Result { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| Error::string("invalid date")) } +/// Creates one or more bookings from the admin form. The selected hour range +/// expands into one row per hour, and — when `repeat_weeks > 1` — the whole +/// block is duplicated on the same weekday for each following week. A slot +/// that is already taken is skipped, so an overlap can never double-book. #[debug_handler] pub async fn booking_create( _auth: AdminAuth, State(ctx): State, - Form(form): Form, + Form(form): Form, ) -> Result { - let date = parse_date(&form.date)?; - bookings::ActiveModel { - court_id: Set(form.court_id), - date: Set(date), - hour: Set(form.hour), - color: Set(form.color), - name: Set(form.name), - title: Set(form.title.filter(|s| !s.is_empty())), - contact: Set(form.contact.filter(|s| !s.is_empty())), - note: Set(form.note.filter(|s| !s.is_empty())), - ..Default::default() + let start_date = parse_date(&form.date)?; + + // Normalise the hour block to `[hour_start, hour_end)`; a non-positive + // span falls back to a single hour so the form cannot create nothing. + let hour_start = form.hour_start.clamp(FIRST_HOUR, LAST_HOUR); + let hour_end = form.hour_end.clamp(hour_start + 1, LAST_HOUR + 1); + let weeks = form.repeat_weeks.unwrap_or(1).clamp(1, 52); + + let title = form.title.filter(|s| !s.is_empty()); + let contact = form.contact.filter(|s| !s.is_empty()); + let note = form.note.filter(|s| !s.is_empty()); + + let last_date = start_date + chrono::Duration::weeks(i64::from(weeks - 1)); + + // Slots already booked on this court within the affected window, used to + // skip conflicts rather than insert a duplicate. + let taken: std::collections::HashSet<(chrono::NaiveDate, i32)> = + bookings::Entity::find() + .filter(bookings::Column::CourtId.eq(form.court_id)) + .filter(bookings::Column::Date.gte(start_date)) + .filter(bookings::Column::Date.lte(last_date)) + .all(&ctx.db) + .await? + .into_iter() + .map(|b| (b.date, b.hour)) + .collect(); + + for w in 0..weeks { + let date = start_date + chrono::Duration::weeks(i64::from(w)); + for hour in hour_start..hour_end { + if taken.contains(&(date, hour)) { + continue; + } + bookings::ActiveModel { + court_id: Set(form.court_id), + date: Set(date), + hour: Set(hour), + color: Set(form.color.clone()), + name: Set(form.name.clone()), + title: Set(title.clone()), + contact: Set(contact.clone()), + note: Set(note.clone()), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } } - .insert(&ctx.db) - .await?; + Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response()) }