mirror of
https://github.com/Garmelon/Arbeitszeitdokumentationsgenerator.git
synced 2026-04-12 08:45:05 +02:00
Render and download PDF when clicking the button
This commit is contained in:
parent
b14284b6ac
commit
6a11bdeb9a
7 changed files with 255 additions and 18 deletions
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -1,5 +1,11 @@
|
||||||
{
|
{
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
Cargo.lock
generated
46
Cargo.lock
generated
|
|
@ -173,6 +173,29 @@ dependencies = [
|
||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "az"
|
name = "az"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -1088,10 +1111,12 @@ version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
"clap",
|
"clap",
|
||||||
"comemo",
|
"comemo",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"maud",
|
"maud",
|
||||||
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"typst",
|
"typst",
|
||||||
|
|
@ -1758,24 +1783,37 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.199"
|
version = "1.0.200"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a"
|
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.199"
|
version = "1.0.200"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc"
|
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.116"
|
version = "1.0.116"
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.82"
|
anyhow = "1.0.82"
|
||||||
axum = "0.7.5"
|
axum = "0.7.5"
|
||||||
|
axum-extra = { version = "0.9.3", features = ["form"] }
|
||||||
clap = { version = "4.5.4", features = ["derive", "deprecated"] }
|
clap = { version = "4.5.4", features = ["derive", "deprecated"] }
|
||||||
comemo = "0.4.0"
|
comemo = "0.4.0"
|
||||||
fontdb = "0.16.2"
|
fontdb = "0.16.2"
|
||||||
maud = { version = "0.26.0", features = ["axum"] }
|
maud = { version = "0.26.0", features = ["axum"] }
|
||||||
|
serde = { version = "1.0.200", features = ["derive"] }
|
||||||
time = { version = "0.3.36", features = ["macros", "formatting", "local-offset"] }
|
time = { version = "0.3.36", features = ["macros", "formatting", "local-offset"] }
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
typst = "0.11.0"
|
typst = "0.11.0"
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,9 @@ button {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
#info.success {
|
||||||
|
color: #070;
|
||||||
|
}
|
||||||
|
#info.error {
|
||||||
|
color: #900;
|
||||||
|
}
|
||||||
|
|
|
||||||
62
src/endpoints/index.js
Normal file
62
src/endpoints/index.js
Normal file
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
|
use axum::{
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::Form;
|
||||||
use maud::{html, Markup, PreEscaped};
|
use maud::{html, Markup, PreEscaped};
|
||||||
|
use serde::Deserialize;
|
||||||
use time::{macros::format_description, OffsetDateTime};
|
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 {
|
pub async fn get() -> Markup {
|
||||||
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
|
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
|
||||||
|
|
@ -11,9 +20,10 @@ pub async fn get() -> Markup {
|
||||||
page(
|
page(
|
||||||
html! {
|
html! {
|
||||||
style { (PreEscaped(include_str!("index.css"))) }
|
style { (PreEscaped(include_str!("index.css"))) }
|
||||||
|
script type="module" { (PreEscaped(include_str!("index.js"))) }
|
||||||
},
|
},
|
||||||
html! {
|
html! {
|
||||||
form {
|
form #form {
|
||||||
h1 { "Arbeitszeitdokumentationsgenerator" }
|
h1 { "Arbeitszeitdokumentationsgenerator" }
|
||||||
|
|
||||||
div #header {
|
div #header {
|
||||||
|
|
@ -28,11 +38,11 @@ pub async fn get() -> Markup {
|
||||||
|
|
||||||
div #gfub {
|
div #gfub {
|
||||||
label #l-gf title="Großforschung" { "GF: "
|
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: "
|
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" {}
|
input #i-monthlyhours name="monthly_hours" type="number" value="40" {}
|
||||||
" Std."
|
" Std."
|
||||||
}
|
}
|
||||||
label #l-hourlyrate for="i-hourlyrate" { "Stundensatz:" }
|
label #l-hourlywage for="i-hourlywage" { "Stundensatz:" }
|
||||||
span {
|
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 { }
|
div { }
|
||||||
|
|
||||||
@for _ in 0..22 {
|
@for _ in 0..22 {
|
||||||
div { input .i-task name="task[]" type="text" {} }
|
div { input .i-task name="task" type="text" {} }
|
||||||
div { input .i-day name="day[]" type="number" value="1" {} }
|
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="start" type="text" placeholder="12:34" {} }
|
||||||
div { input .i-dur name="end[]" 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 { input .i-dur name="rest" type="text" placeholder="00:00" {} }
|
||||||
div { select name="note[]" value="" {
|
div { select name="note" value="" {
|
||||||
option value="" { "Normal" }
|
option value="" { "Normal" }
|
||||||
option value="U" { "Urlaub" }
|
option value="U" { "Urlaub" }
|
||||||
option value="K" { "Krankheit" }
|
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<String>,
|
||||||
|
day: Vec<u32>,
|
||||||
|
start: Vec<String>,
|
||||||
|
end: Vec<String>,
|
||||||
|
rest: Vec<String>,
|
||||||
|
note: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response<S: ToString>(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::<u32>().ok()?;
|
||||||
|
let month = parts.next()?.parse::<u32>().ok()?;
|
||||||
|
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((year, month))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(form: Form<PostForm>) -> 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::<Vec<_>>();
|
||||||
|
|
||||||
|
// 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::<Vec<_>>();
|
||||||
|
|
||||||
|
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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ struct Args {
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = Args::parse();
|
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?;
|
let listener = TcpListener::bind(args.addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue