feat: only allow one availability for a time

This commit is contained in:
Max Hohlfeld 2025-01-26 19:21:34 +01:00
parent 2cae816741
commit 864141121a
7 changed files with 160 additions and 27 deletions

View File

@ -2,9 +2,10 @@ use actix_web::{web, HttpResponse, Responder};
use chrono::NaiveDate; use chrono::NaiveDate;
use rinja::Template; use rinja::Template;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool;
use crate::endpoints::availability::NewOrEditAvailabilityTemplate; use crate::endpoints::availability::{calc_free_slots_cor, NewOrEditAvailabilityTemplate};
use crate::models::User; use crate::models::{Availabillity, User};
use crate::utils::ApplicationError; use crate::utils::ApplicationError;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -17,8 +18,32 @@ struct AvailabilityNewQuery {
#[actix_web::get("/availabillity/new")] #[actix_web::get("/availabillity/new")]
pub async fn get( pub async fn get(
user: web::ReqData<User>, user: web::ReqData<User>,
pool: web::Data<PgPool>,
query: web::Query<AvailabilityNewQuery>, query: web::Query<AvailabilityNewQuery>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
let availabillities = Availabillity::read_by_date_and_area_including_user(
pool.get_ref(),
query.date,
user.area_id,
)
.await?;
let availabilities_from_user: Vec<&Availabillity> = availabillities
.iter()
.filter(|a| a.user_id == user.id)
.collect();
let only_one_availability_is_wholeday = availabilities_from_user.len() == 1
&& availabilities_from_user[0].start_time.is_none()
&& availabilities_from_user[0].end_time.is_none();
let user_can_create_availabillity = availabilities_from_user.len() == 0
|| !(only_one_availability_is_wholeday
|| calc_free_slots_cor(&availabilities_from_user).len() == 0);
if !user_can_create_availabillity {
return Ok(HttpResponse::BadRequest().finish());
}
let template = NewOrEditAvailabilityTemplate { let template = NewOrEditAvailabilityTemplate {
user: user.into_inner(), user: user.into_inner(),
date: query.date, date: query.date,
@ -27,6 +52,7 @@ pub async fn get(
start_time: None, start_time: None,
end_time: None, end_time: None,
comment: None, comment: None,
slot_suggestions: calc_free_slots_cor(&availabilities_from_user),
}; };
Ok(HttpResponse::Ok().body(template.render()?)) Ok(HttpResponse::Ok().body(template.render()?))

View File

@ -1,10 +1,8 @@
use crate::{ use crate::{
filters, endpoints::availability::calc_free_slots_cor, filters, models::{Assignment, Function, Vehicle}, utils::{event_planning_template::generate_vehicles_assigned_and_available, ApplicationError}
models::{Assignment, Function, Vehicle},
utils::{event_planning_template::generate_vehicles_assigned_and_available, ApplicationError},
}; };
use actix_web::{web, HttpResponse, Responder}; use actix_web::{web, HttpResponse, Responder};
use chrono::{NaiveDate, Utc}; use chrono::{NaiveDate, NaiveTime, Utc};
use rinja::Template; use rinja::Template;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
@ -21,10 +19,17 @@ pub struct CalendarQuery {
#[template(path = "index.html")] #[template(path = "index.html")]
struct CalendarTemplate { struct CalendarTemplate {
user: User, user: User,
user_can_create_availabillity: bool,
date: NaiveDate, date: NaiveDate,
selected_area: Option<i32>, selected_area: Option<i32>,
areas: Vec<Area>, areas: Vec<Area>,
events_and_assignments: Vec<(Event, Vec<String>, Option<String>, Option<String>, Vec<Vehicle>)>, events_and_assignments: Vec<(
Event,
Vec<String>,
Option<String>,
Option<String>,
Vec<Vehicle>,
)>,
availabillities: Vec<Availabillity>, availabillities: Vec<Availabillity>,
} }
@ -59,6 +64,18 @@ async fn get(
) )
.await?; .await?;
let availabilities_from_user: Vec<&Availabillity> = availabillities
.iter()
.filter(|a| a.user_id == user.id)
.collect();
let only_one_availability_is_wholeday = availabilities_from_user.len() == 1
&& availabilities_from_user[0].start_time.is_none()
&& availabilities_from_user[0].end_time.is_none();
let user_can_create_availabillity = availabilities_from_user.len() == 0
|| !(only_one_availability_is_wholeday
|| calc_free_slots_cor(&availabilities_from_user).len() == 0);
let mut events_and_assignments = Vec::new(); let mut events_and_assignments = Vec::new();
for e in Event::read_all_by_date_and_area_including_location( for e in Event::read_all_by_date_and_area_including_location(
pool.get_ref(), pool.get_ref(),
@ -120,12 +137,13 @@ async fn get(
.clone(), .clone(),
) )
}), }),
assigned_vehicle assigned_vehicle,
)); ));
} }
let template = CalendarTemplate { let template = CalendarTemplate {
user: user.into_inner(), user: user.into_inner(),
user_can_create_availabillity,
date, date,
selected_area, selected_area,
areas, areas,

View File

@ -4,7 +4,10 @@ use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::{ use crate::{
endpoints::{availability::NewOrEditAvailabilityTemplate, IdPath}, endpoints::{
availability::{calc_free_slots_cor, NewOrEditAvailabilityTemplate},
IdPath,
},
models::{Availabillity, User}, models::{Availabillity, User},
utils::ApplicationError, utils::ApplicationError,
}; };
@ -40,6 +43,29 @@ pub async fn get(
let has_time = availabillity.start_time.is_some() && availabillity.end_time.is_some(); let has_time = availabillity.start_time.is_some() && availabillity.end_time.is_some();
let suggestions = if has_time {
let availabillities = Availabillity::read_by_date_and_area_including_user(
pool.get_ref(),
availabillity.date,
user.area_id,
)
.await?;
let availabilities_from_user: Vec<&Availabillity> = availabillities
.iter()
.filter(|a| a.user_id == user.id)
.collect();
calc_free_slots_cor(&availabilities_from_user)
.into_iter()
.filter(|(a, b)| {
*b == availabillity.start_time.unwrap() || *a == availabillity.end_time.unwrap()
})
.collect()
} else {
Vec::new()
};
let template = NewOrEditAvailabilityTemplate { let template = NewOrEditAvailabilityTemplate {
user: user.into_inner(), user: user.into_inner(),
date: availabillity.date, date: availabillity.date,
@ -48,6 +74,7 @@ pub async fn get(
start_time: start_time.as_deref(), start_time: start_time.as_deref(),
end_time: end_time.as_deref(), end_time: end_time.as_deref(),
comment: availabillity.comment.as_deref(), comment: availabillity.comment.as_deref(),
slot_suggestions: suggestions,
}; };
Ok(HttpResponse::Ok().body(template.render()?)) Ok(HttpResponse::Ok().body(template.render()?))

View File

@ -1,8 +1,8 @@
use chrono::{NaiveDate, NaiveTime};
use rinja::Template; use rinja::Template;
use chrono::NaiveDate;
use crate::filters; use crate::filters;
use crate::models::{Role, User}; use crate::models::{Availabillity, Role, User};
pub mod delete; pub mod delete;
pub mod get_new; pub mod get_new;
@ -21,4 +21,72 @@ struct NewOrEditAvailabilityTemplate<'a> {
start_time: Option<&'a str>, start_time: Option<&'a str>,
end_time: Option<&'a str>, end_time: Option<&'a str>,
comment: Option<&'a str>, comment: Option<&'a str>,
slot_suggestions: Vec<(NaiveTime, NaiveTime)>,
}
fn calc_free_slots_cor(availabilities: &Vec<&Availabillity>) -> Vec<(NaiveTime, NaiveTime)> {
let mut times = Vec::new();
for a in availabilities {
let Some(start_time) = a.start_time else {
continue;
};
let Some(end_time) = a.end_time else {
continue;
};
times.push((start_time, end_time));
}
if times.len() == 0 {
return Vec::new();
}
println!("zeiten {times:?}");
times.sort();
println!("zeiten sort {times:?}");
let mut changed = true;
while changed {
changed = false;
for i in 0..(times.len() - 1) {
let b = times[i + 1].clone();
let a = times.get_mut(i).unwrap();
if a.1 == b.0 {
a.1 = b.1;
times.remove(i + 1);
changed = true;
break;
}
}
}
let start = NaiveTime::parse_from_str("00:00", "%R").unwrap();
let end = NaiveTime::parse_from_str("23:59", "%R").unwrap();
println!("zeiten unified {times:?}");
// now times contains unified list of existing availabilities -> now calculate the "inverse"
let mut available_slots = Vec::new();
if times.first().unwrap().0 != start {
available_slots.push((start, times.first().unwrap().0));
}
let mut iterator = times.iter().peekable();
while let Some(a) = iterator.next() {
if let Some(b) = iterator.peek() {
available_slots.push((a.1, b.0));
}
}
if times.last().unwrap().1 != end {
available_slots.push((times.last().unwrap().1, end));
}
println!("available {available_slots:?}");
available_slots
} }

View File

@ -1,5 +1,3 @@
use std::fmt::Display;
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
use sqlx::{query, PgPool}; use sqlx::{query, PgPool};
@ -30,18 +28,6 @@ pub enum AvailabillityAssignmentState {
AssignedWachhabender(i32), AssignedWachhabender(i32),
} }
impl Display for AvailabillityAssignmentState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AvailabillityAssignmentState::Unassigned => write!(f, "nicht zugewiesen"),
AvailabillityAssignmentState::Conflicting => write!(f, "bereits anders zugewiesen"),
AvailabillityAssignmentState::AssignedPosten(_) => write!(f, "zugewiesen als Posten"),
AvailabillityAssignmentState::AssignedFührungsassistent(_) => write!(f, "zugewiesen als Führungsassistent"),
AvailabillityAssignmentState::AssignedWachhabender(_) => write!(f, "zugewiesen als Wachhabender"),
}
}
}
impl Availabillity { impl Availabillity {
pub async fn create( pub async fn create(
pool: &PgPool, pool: &PgPool,

View File

@ -52,6 +52,12 @@
<div class="field"> <div class="field">
<input class="input" type="time" id="from" name="from" value='{{ start_time.unwrap_or("00:00") }}' {{ <input class="input" type="time" id="from" name="from" value='{{ start_time.unwrap_or("00:00") }}' {{
whole_day|cond_show("disabled") }} {{ whole_day|invert|ref|cond_show("required") }}> whole_day|cond_show("disabled") }} {{ whole_day|invert|ref|cond_show("required") }}>
<p class="help">noch mögliche Zeiträume:</p>
<div class="tags help">
{% for (s, e) in slot_suggestions %}
<span class="tag">{{ s }} - {{ e }}</span>
{% endfor %}
</div>
</div> </div>
<div class="field"> <div class="field">
<input class="input" type="time" id="till" name="till" value='{{ end_time.unwrap_or("23:59") }}' {{ <input class="input" type="time" id="till" name="till" value='{{ end_time.unwrap_or("23:59") }}' {{

View File

@ -183,12 +183,14 @@
</div> </div>
{% if selected_area.is_none() || selected_area.unwrap() == user.area_id %} {% if selected_area.is_none() || selected_area.unwrap() == user.area_id %}
<div class="level-right"> <div class="level-right">
<a class="button is-link is-light" hx-boost="true" href="/availabillity/new?date={{ date }}"> {% let btn_disabled = !user_can_create_availabillity %}
<button class="button is-link is-light" hx-get="/availabillity/new?date={{ date }}" {{
btn_disabled|cond_show("disabled") }} hx-target="closest body">
<svg class="icon"> <svg class="icon">
<use href="/static/feather-sprite.svg#plus-circle" /> <use href="/static/feather-sprite.svg#plus-circle" />
</svg> </svg>
<span>Neue Verfügbarkeit für diesen Tag</span> <span>Neue Verfügbarkeit für diesen Tag</span>
</a> </button>
</div> </div>
{% endif %} {% endif %}
</div> </div>