From 6a11bdeb9a58479855aee3a2ff9a69f344f04090 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 5 May 2024 00:12:50 +0200 Subject: [PATCH] Render and download PDF when clicking the button --- .vscode/settings.json | 6 ++ Cargo.lock | 46 +++++++++++-- Cargo.toml | 2 + src/endpoints/index.css | 6 ++ src/endpoints/index.js | 62 +++++++++++++++++ src/endpoints/index.rs | 148 ++++++++++++++++++++++++++++++++++++---- src/main.rs | 3 +- 7 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 src/endpoints/index.js diff --git a/.vscode/settings.json b/.vscode/settings.json index d770d54..a759fb8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/Cargo.lock b/Cargo.lock index 3c2bb40..06f330e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "serde_html_form", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "az" version = "1.2.1" @@ -1088,10 +1111,12 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "axum-extra", "clap", "comemo", "fontdb", "maud", + "serde", "time", "tokio", "typst", @@ -1758,24 +1783,37 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.199" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a" +checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.199" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc" +checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_json" version = "1.0.116" diff --git a/Cargo.toml b/Cargo.toml index 0525f95..ddb6be3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,12 @@ edition = "2021" [dependencies] anyhow = "1.0.82" axum = "0.7.5" +axum-extra = { version = "0.9.3", features = ["form"] } clap = { version = "4.5.4", features = ["derive", "deprecated"] } comemo = "0.4.0" fontdb = "0.16.2" maud = { version = "0.26.0", features = ["axum"] } +serde = { version = "1.0.200", features = ["derive"] } time = { version = "0.3.36", features = ["macros", "formatting", "local-offset"] } tokio = { version = "1.37.0", features = ["full"] } typst = "0.11.0" diff --git a/src/endpoints/index.css b/src/endpoints/index.css index fa2875c..6df522d 100644 --- a/src/endpoints/index.css +++ b/src/endpoints/index.css @@ -81,3 +81,9 @@ button { margin: 0 auto; font-size: 1.5em; } +#info.success { + color: #070; +} +#info.error { + color: #900; +} diff --git a/src/endpoints/index.js b/src/endpoints/index.js new file mode 100644 index 0000000..521ed85 --- /dev/null +++ b/src/endpoints/index.js @@ -0,0 +1,62 @@ +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); + + try { + showStatus("Generiere..."); + + const response = await fetch("/", { + method: "post", + body: new URLSearchParams(data), + }); + + 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}`); + } +}); diff --git a/src/endpoints/index.rs b/src/endpoints/index.rs index ff06a08..f8c7724 100644 --- a/src/endpoints/index.rs +++ b/src/endpoints/index.rs @@ -1,7 +1,16 @@ +use axum::{ + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use axum_extra::extract::Form; use maud::{html, Markup, PreEscaped}; +use serde::Deserialize; use time::{macros::format_description, OffsetDateTime}; -use crate::endpoints::page; +use crate::{ + endpoints::page, + render::{self, Entry, Note, Timesheet, WorkingArea}, +}; pub async fn get() -> Markup { let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); @@ -11,9 +20,10 @@ pub async fn get() -> Markup { page( html! { style { (PreEscaped(include_str!("index.css"))) } + script type="module" { (PreEscaped(include_str!("index.js"))) } }, html! { - form { + form #form { h1 { "Arbeitszeitdokumentationsgenerator" } div #header { @@ -28,11 +38,11 @@ pub async fn get() -> Markup { div #gfub { label #l-gf title="Großforschung" { "GF: " - input #i-gf name="working_area" type="radio" value="gf" {} + input #i-gf name="working_area" type="radio" value="GF" {} } label #l-ub for="i-ub" title="Unibereich" { "UB: " - input #i-ub name="working_area" type="radio" value="ub" {} + input #i-ub name="working_area" type="radio" value="UB" checked {} } } @@ -45,9 +55,9 @@ pub async fn get() -> Markup { input #i-monthlyhours name="monthly_hours" type="number" value="40" {} " Std." } - label #l-hourlyrate for="i-hourlyrate" { "Stundensatz:" } + label #l-hourlywage for="i-hourlywage" { "Stundensatz:" } span { - input #i-hourlyrate name="hourly_rate" type="number" step="0.01" placeholder="14.09" {} + input #i-hourlywage name="hourly_wage" type="number" step="0.01" placeholder="14.09" {} " €" } } @@ -67,12 +77,12 @@ pub async fn get() -> Markup { div { } @for _ in 0..22 { - div { input .i-task name="task[]" type="text" {} } - div { input .i-day name="day[]" type="number" value="1" {} } - div { input .i-dur name="start[]" type="text" placeholder="12:34" {} } - div { input .i-dur name="end[]" type="text" placeholder="12:34" {} } - div { input .i-dur name="pause[]" type="text" placeholder="01:23" value="00:00" {} } - div { select name="note[]" value="" { + div { input .i-task name="task" type="text" {} } + div { input .i-day name="day" type="number" value="1" {} } + div { input .i-dur name="start" type="text" placeholder="12:34" {} } + div { input .i-dur name="end" type="text" placeholder="12:34" {} } + div { input .i-dur name="rest" type="text" placeholder="00:00" {} } + div { select name="note" value="" { option value="" { "Normal" } option value="U" { "Urlaub" } option value="K" { "Krankheit" } @@ -82,8 +92,120 @@ pub async fn get() -> Markup { } } - button #submit { "Arbeitszeitdokumentation erstellen" } + button #submit type="button" { "Arbeitszeitdokumentation generieren" } + + pre #info {} } }, ) } + +#[derive(Debug, Deserialize)] +pub struct PostForm { + month: String, + name: String, + staff_id: String, + working_area: String, + department: String, + monthly_hours: u32, + hourly_wage: String, + task: Vec, + day: Vec, + start: Vec, + end: Vec, + rest: Vec, + note: Vec, +} + +fn error_response(msg: S) -> Response { + (StatusCode::BAD_REQUEST, msg.to_string()).into_response() +} + +fn parse_month(month_str: &str) -> Option<(u32, u32)> { + let mut parts = month_str.split('-'); + + let year = parts.next()?.parse::().ok()?; + let month = parts.next()?.parse::().ok()?; + + if parts.next().is_some() { + return None; + } + + Some((year, month)) +} + +pub async fn post(form: Form) -> Response { + let form = form.0; + + // Parse working area + let working_area = match &form.working_area as &str { + "GF" => WorkingArea::Großforschung, + "UB" => WorkingArea::Unibereich, + _ => return error_response(format!("invalid working area: {:?}", form.working_area)), + }; + + // Parse month + let Some((year, month)) = parse_month(&form.month) else { + return error_response(format!("invalid month: {:?}", form.month)); + }; + + // Parse rests + let rests = form + .rest + .into_iter() + .map(|r| if r.is_empty() { None } else { Some(r) }) + .collect::>(); + + // Parse notes + let mut notes = vec![]; + for note in form.note { + let note = match ¬e as &str { + "" => None, + "U" => Some(Note::Urlaub), + "K" => Some(Note::Krankheit), + "F" => Some(Note::Feiertag), + "S" => Some(Note::Sonstiges), + _ => return error_response(format!("invalid note: {note:?}")), + }; + notes.push(note) + } + + let entries = (form.task.into_iter()) + .zip(form.day.into_iter()) + .zip(form.start.into_iter()) + .zip(form.end.into_iter()) + .zip(rests.into_iter()) + .zip(notes.into_iter()) + .filter_map(|(((((task, day), start), end), rest), note)| { + if task.is_empty() || start.is_empty() || end.is_empty() { + return None; + }; + Some(Entry { + task, + day, + start, + end, + rest, + note, + }) + }) + .collect::>(); + + let timesheet = Timesheet { + name: form.name, + staff_id: form.staff_id, + department: form.department, + working_area, + monthly_hours: form.monthly_hours, + hourly_wage: form.hourly_wage, + validate: true, + year, + month, + entries, + }; + + match render::render(timesheet) { + Ok(pdf) => ([(header::CONTENT_TYPE, "application/pdf")], pdf).into_response(), + Err(errors) => error_response(errors.join("\n")), + } +} diff --git a/src/main.rs b/src/main.rs index daeb7db..d5fef89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,8 @@ struct Args { async fn main() -> anyhow::Result<()> { let args = Args::parse(); - let app = Router::<()>::new().route("/", get(endpoints::index::get)); + let app = + Router::<()>::new().route("/", get(endpoints::index::get).post(endpoints::index::post)); let listener = TcpListener::bind(args.addr).await?; axum::serve(listener, app).await?;