Add TimeSheetGenerator schema compatible page

This commit is contained in:
Joscha 2024-05-05 03:39:40 +02:00
parent 722c05f983
commit 8f9d304761
6 changed files with 265 additions and 2 deletions

View file

@ -1,6 +1,7 @@
use maud::{html, Markup}; use maud::{html, Markup};
pub mod index; pub mod index;
pub mod tsg;
fn page(head: Markup, body: Markup) -> Markup { fn page(head: Markup, body: Markup) -> Markup {
html! { html! {

View file

@ -26,6 +26,13 @@ pub async fn get() -> Markup {
form #form { form #form {
h1 { "Arbeitszeitdokumentationsgenerator" } h1 { "Arbeitszeitdokumentationsgenerator" }
p {
"Want to use "
a href="https://github.com/kit-sdq/TimeSheetGenerator" { "TimeSheetGenerator" }
"-compatible JSON instead? "
a href="/tsg" { "Go here!" }
}
div #header { div #header {
label #l-month for="i-month" { "Monat / Jahr:" } label #l-month for="i-month" { "Monat / Jahr:" }
input #i-month name="month" type="month" placeholder=(month) value=(month) {} input #i-month name="month" type="month" placeholder=(month) value=(month) {}

31
src/endpoints/tsg.css Normal file
View file

@ -0,0 +1,31 @@
:root {
font-family: Arial, FreeSans, sans-serif;
}
form {
max-width: 210mm; /* DIN-A 4 */
margin: 0 auto;
padding: 0 5mm 5mm;
border: 2px solid black;
border-radius: 0 8mm 0 8mm;
}
h1 {
color: #009682;
text-align: center;
}
textarea {
display: block;
width: 100%;
height: 14lh;
margin: 1mm 0;
}
button {
display: block;
margin: 4mm auto 0;
font-size: 1.5em;
}
#info.success {
color: #070;
}
#info.error {
color: #900;
}

67
src/endpoints/tsg.js Normal file
View file

@ -0,0 +1,67 @@
const form = document.getElementById("form");
const submit = document.getElementById("submit");
const info = document.getElementById("info");
function showStatus(msg) {
info.classList.remove("success");
info.classList.remove("error");
info.textContent = msg;
info.scrollIntoView();
}
function showSuccess(msg) {
info.classList.add("success");
info.classList.remove("error");
info.textContent = msg;
info.scrollIntoView();
}
function showError(msg) {
info.classList.add("error");
info.classList.remove("success");
info.textContent = msg;
info.scrollIntoView();
}
submit.addEventListener("click", async () => {
const data = new FormData(form);
const dataJson = JSON.stringify({
global: JSON.parse(data.get("global")),
month: JSON.parse(data.get("month")),
});
try {
showStatus("Generiere...");
const response = await fetch("/tsg", {
method: "post",
headers: { "Content-Type": "application/json" },
body: dataJson,
});
if (response.status !== 200) {
const reason = await response.text();
showError(`Generieren fehlgeschlagen:\n${reason}`);
return;
}
let blob = await response.blob();
const reader = new FileReader();
reader.addEventListener("loadend", () => {
let element = document.createElement("a");
element.setAttribute("href", reader.result);
element.setAttribute("download", "Arbeitszeitdokumentation.pdf");
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
showSuccess("Generieren erfolgreich!");
});
reader.readAsDataURL(blob);
} catch (e) {
showError(`Generieren fehlgeschlagen:\n${e}`);
}
});

156
src/endpoints/tsg.rs Normal file
View file

@ -0,0 +1,156 @@
use axum::{
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use maud::{html, Markup, PreEscaped};
use serde::Deserialize;
use crate::{
endpoints::page,
render::{self, Entry, Note, Timesheet, WorkingArea},
};
pub async fn get() -> Markup {
page(
html! {
style { (PreEscaped(include_str!("tsg.css"))) }
script type="module" { (PreEscaped(include_str!("tsg.js"))) }
},
html! {
form #form {
h1 { "Arbeitszeitdokumentationsgenerator" }
p {
"Want to use a fancy-looking form instead? "
a href="/" { "Go here!" }
}
textarea name="global" placeholder="Global.json" {}
textarea name="month" placeholder="Month.json" {}
button #submit type="button" { "Arbeitszeitdokumentation generieren" }
pre #info {}
}
},
)
}
fn default_vacation() -> bool {
false
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalJson {
name: String,
staff_id: i64,
department: String,
working_time: String,
wage: f64,
working_area: String,
}
#[derive(Debug, Deserialize)]
pub struct EntryJson {
action: String,
day: u32,
start: String,
end: String,
pause: Option<String>,
#[serde(default = "default_vacation")]
vacation: bool,
}
#[derive(Debug, Deserialize)]
pub struct MonthJson {
year: u32,
month: u32,
pred_transfer: Option<String>,
entries: Vec<EntryJson>,
}
#[derive(Debug, Deserialize)]
pub struct PostJson {
global: GlobalJson,
month: MonthJson,
}
fn error_response<S: ToString>(msg: S) -> Response {
(StatusCode::BAD_REQUEST, msg.to_string()).into_response()
}
fn parse_span(span_str: &str) -> Option<u32> {
let mut parts = span_str.split(':');
let hours = parts.next()?.parse::<u32>().ok()?;
let minutes = parts.next()?.parse::<u32>().ok()?;
if parts.next().is_some() {
return None;
}
if minutes != 0 {
return None;
}
Some(hours)
}
pub async fn post(json: Json<PostJson>) -> Response {
let json = json.0;
// Parse working area
let working_area = match &json.global.working_area as &str {
"gf" => WorkingArea::Großforschung,
"ub" => WorkingArea::Unibereich,
_ => {
return error_response(format!(
"invalid working area: {:?}",
json.global.working_area
))
}
};
// Parse working time
let Some(monthly_hours) = parse_span(&json.global.working_time) else {
return error_response(format!(
"invalid working_time: {:?}",
json.global.working_time
));
};
let entries = json
.month
.entries
.into_iter()
.map(|e| Entry {
task: e.action,
day: e.day,
start: e.start,
end: e.end,
rest: e.pause,
note: if e.vacation { Some(Note::Urlaub) } else { None },
})
.collect::<Vec<_>>();
let timesheet = Timesheet {
name: json.global.name,
staff_id: json.global.staff_id.to_string(),
department: json.global.department,
working_area,
monthly_hours,
hourly_wage: json.global.wage.to_string(),
validate: true,
carry_prev_month: json.month.pred_transfer,
year: json.month.year,
month: json.month.month,
entries,
};
match render::render(timesheet) {
Ok(pdf) => ([(header::CONTENT_TYPE, "application/pdf")], pdf).into_response(),
Err(errors) => error_response(errors.join("\n")),
}
}

View file

@ -14,8 +14,9 @@ struct Args {
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let args = Args::parse(); let args = Args::parse();
let app = let app = Router::<()>::new()
Router::<()>::new().route("/", get(endpoints::index::get).post(endpoints::index::post)); .route("/", get(endpoints::index::get).post(endpoints::index::post))
.route("/tsg", get(endpoints::tsg::get).post(endpoints::tsg::post));
let listener = TcpListener::bind(args.addr).await?; let listener = TcpListener::bind(args.addr).await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;