diff --git a/src/endpoints.rs b/src/endpoints.rs index a101acd..f57831a 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -1,6 +1,7 @@ use maud::{html, Markup}; pub mod index; +pub mod tsg; fn page(head: Markup, body: Markup) -> Markup { html! { diff --git a/src/endpoints/index.rs b/src/endpoints/index.rs index cb74c6c..a8ca7e1 100644 --- a/src/endpoints/index.rs +++ b/src/endpoints/index.rs @@ -26,6 +26,13 @@ pub async fn get() -> Markup { form #form { 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 { label #l-month for="i-month" { "Monat / Jahr:" } input #i-month name="month" type="month" placeholder=(month) value=(month) {} diff --git a/src/endpoints/tsg.css b/src/endpoints/tsg.css new file mode 100644 index 0000000..ed60750 --- /dev/null +++ b/src/endpoints/tsg.css @@ -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; +} diff --git a/src/endpoints/tsg.js b/src/endpoints/tsg.js new file mode 100644 index 0000000..6e8f490 --- /dev/null +++ b/src/endpoints/tsg.js @@ -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}`); + } +}); diff --git a/src/endpoints/tsg.rs b/src/endpoints/tsg.rs new file mode 100644 index 0000000..7f5f019 --- /dev/null +++ b/src/endpoints/tsg.rs @@ -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, + #[serde(default = "default_vacation")] + vacation: bool, +} + +#[derive(Debug, Deserialize)] +pub struct MonthJson { + year: u32, + month: u32, + pred_transfer: Option, + entries: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PostJson { + global: GlobalJson, + month: MonthJson, +} + +fn error_response(msg: S) -> Response { + (StatusCode::BAD_REQUEST, msg.to_string()).into_response() +} + +fn parse_span(span_str: &str) -> Option { + let mut parts = span_str.split(':'); + + let hours = parts.next()?.parse::().ok()?; + let minutes = parts.next()?.parse::().ok()?; + + if parts.next().is_some() { + return None; + } + + if minutes != 0 { + return None; + } + + Some(hours) +} + +pub async fn post(json: Json) -> 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::>(); + + 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")), + } +} diff --git a/src/main.rs b/src/main.rs index d5fef89..28a3466 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,9 @@ struct Args { async fn main() -> anyhow::Result<()> { let args = Args::parse(); - let app = - Router::<()>::new().route("/", get(endpoints::index::get).post(endpoints::index::post)); + let app = Router::<()>::new() + .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?; axum::serve(listener, app).await?;