mirror of
https://github.com/Garmelon/Arbeitszeitdokumentationsgenerator.git
synced 2026-04-12 16:55:04 +02:00
Add TimeSheetGenerator schema compatible page
This commit is contained in:
parent
722c05f983
commit
8f9d304761
6 changed files with 265 additions and 2 deletions
|
|
@ -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! {
|
||||||
|
|
|
||||||
|
|
@ -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
31
src/endpoints/tsg.css
Normal 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
67
src/endpoints/tsg.js
Normal 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
156
src/endpoints/tsg.rs
Normal 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue