diff --git a/README.md b/README.md index 12ada0c..b2b316a 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,12 @@ inspired by the [SDQ TimeSheetGenerator][tsg] and a friend's very useful wrapper website that spits out PDFs directly. [tsg]: https://github.com/kit-sdq/TimeSheetGenerator + +## Typst template + +The centerpiece of the repository is a [typst][typst] template that generates +and validates time sheets. It aims to mirror the look of the official form as +closely as possible. For usage information, see [its docs][tdocs]. + +[typst]: https://github.com/typst/typst +[tdocs]: kit_timesheet.md diff --git a/kit_logo.svg b/kit_logo.svg new file mode 100644 index 0000000..16d7c88 --- /dev/null +++ b/kit_logo.svg @@ -0,0 +1,47 @@ + +KIT - Karlsruher Institut für Technologie + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kit_timesheet.md b/kit_timesheet.md new file mode 100644 index 0000000..282e41e --- /dev/null +++ b/kit_timesheet.md @@ -0,0 +1,132 @@ +# The `kit_timesheet` typst module + +This file exhaustively documents the `kit_timesheet` typst module and its +exported values. + +## Quickstart + +```typst +#import "kit_timesheet.typ" as ts + +#ts.timesheet( + name: [McStudentface, Student], + staff_id: 1337420, + department: [Institut für Informatik], + working_area: ts.areas.Großforschung, + monthly_hours: 40, + hourly_wage: [14.09], + carry_prev_month: "02:30", + year: 2024, + month: 1, + ts.entry("Urlaub", 2, "12:00", "14:00", note: ts.notes.Urlaub), + ts.entry("Mate trinken", 3, "14:00", "15:30"), + ts.entry("Im Bett liegen", 4, "10:00", "12:00"), + ts.entry("Stundenzettel ausfüllen", 31, "14:00", "15:30", rest: "01:00"), +) +``` + +## Times and durations + +Some function arguments represent a time of day or a duration. They expect a +string of the form `HH:MM` or `-HH:MM`, e.g. `12:34`. More specifically, the +string must match the regex `^-?[0-9]+:[0-5][0-9]$`. + +This makes it possible to specify negative durations, which can be useful if you +didn't work enough hours last month and want to specify a negative +`carry_last_month` value. + +A time of day is just a duration starting from midnight (00:00). + +## Members + +### `areas` (dictionary) + +The `areas` dict contains the enum-like string values accepted by the +`working_area` parameter of the `timesheet` function. See its documentation for +more detail. + +Available entries are: + +- `areas.Großforschung` (value: `"GF"`) +- `areas.Unibereich` (value: `"UB"`) + +### `notes` (dictionary) + +The `notes` dict contains the enum-like string values accepted by the `note` +parameter of the `entry` function. See its documentation as well as footnote 1 +in the generated document for more detail. + +Available entries are: + +- `notes.Urlaub` (value: `"U"`) +- `notes.Krankheit` (value: `"K"`) +- `notes.Feiertage` (value: `"F"`) +- `notes.Sonstiges` (value: `"S"`) + +### `entry` (function) + +Create a single entry for the `entries` parameter of the `timesheet` function. +An entry corresponds exactly to a row in the resulting document. + +Positional arguments: + +- `task`: + What you worked on. Corresponds to the _Tätigkeit_ column of the table. +- `day`: + The day of month. Corresponds to the _Datum_ column of the table. +- `start`: + When you started working. Corresponds to the _Beginn_ column of the table. +- `end`: + When you stopped working. Corresponds to the _Ende_ column of the table. + +Named arguments: + +- `rest` (default: `"00:00"`): + Break time. Corresponds to the _Pause_ column of the table. If it wasn't a + keyword, I'd have called it `break` :D +- `note` (default: `none`): + Additional note for the _Arbeitszeit_ column (whose value is automatically + calculated). Entries with a note of `notes.Urlaub` are used to calculate the + _Urlaub anteilig_ field in the summary table. See the `notes` dictionary for + all available values. + +### `timesheet` (function) + +Generate and validate a full timesheet. + +Positional arguments: + +- `..entries`: + All positional arguments are entries for the big table, created using the + `entry` function. + +Named arguments: + +- `name`: + Your name (Name, Vorname). +- `staff_id`: + Your staff id (Personalnummer). +- `department`: + Your department (Organisationseinheit/OE). +- `working_area`: + Your working area (Großforschung/GF or Unibereich/UB). Corresponds to the _GF_ + and _UB_ checkboxes on the form. See the `areas` dictionary for all available + values. +- `monthly_hours`: + How many hours per month your contract says you should work (Vertraglich + vereinbarte Arbeitszeit). +- `hourly_wage`: + Your hourly wage (Stundensatz). +- `validate` (default: `true`): + Whether the template should try to validate the data you entered (check if + values look wrong, if you worked on a holiday, ...). If you turn this off, you + can do funky things like work a negative amount of time or on Sundays. +- `year`: + The year this time sheet is being generated for. +- `month`: + The month this time sheet is being generated for. + +### `timesheet_empty` (function) + +Generate an empty timesheet. Useful if you want to fill it out by hand. Not sure +why anyone would want to do this though :P diff --git a/kit_timesheet.typ b/kit_timesheet.typ new file mode 100644 index 0000000..530ac78 --- /dev/null +++ b/kit_timesheet.typ @@ -0,0 +1,374 @@ +///////////// +// "Enums" // +///////////// + +#let areas = (Großforschung: "GF", Unibereich: "UB") +#let notes = (Urlaub: "U", Krankheit: "K", Feiertag: "F", Sonstiges: "S") + +//////////// +// Layout // +//////////// + +#let _kit_green = rgb("#009682") +#let _kit_stroke = 0.2mm + +#let _frame(body) = { + set text(lang: "de", font: "Liberation Sans") + set page(margin: (top: 9.5mm, bottom: 12mm, left: 15mm, right: 10mm)) + set block(spacing: 1mm) + set par(leading: 5pt) + + // Weird vertical text in the bottom left + place(bottom + left, dx: -6mm, dy: -1mm, + rotate(-90deg, origin: bottom + left, + text(size: 6pt, tracking: 3.1pt, + "K_PSE_PB_AZDoku_01_04-20" + ) + ) + ) + + // Main box + rect( + width: 100%, + height: 100%, + stroke: _kit_stroke, + radius: (top-right: 4.5mm, bottom-left: 4.5mm), + inset: 0mm + )[ + // Logo + #place(top + left, dx: 9.5mm, dy: 3.5mm, + image("kit_logo.svg", width: 29mm) + ) + + // Heading + #place(top + left, dx: 78mm, dy: 9mm, + text(weight: "bold", size: 14pt, fill: _kit_green, + [Arbeitszeitdokumentation] + ) + ) + + // Page number + #place(bottom + right, dx: -15mm, dy: -1.5mm, + text(size: 9pt, [Seite 1 von 1]) + ) + + // Main content + #block( + inset: (top: 24.5mm, left: 7.5mm, right: 13mm), + width: 100%, + height: 100%, + body + ) + ] +} + +#let _underlined(body) = box( + baseline: 0%, + stroke: (bottom: _kit_stroke), + outset: (bottom: 1.5mm), + inset: (x: 1mm), + body, +) + +#let _checkbox(checked) = box( + width: 3.5mm, + outset: (y: 0.4mm), + stroke: _kit_stroke, + align(center + horizon, if checked { "X" } else { " " }) +) + +#let _header( + year: " ", + month: " ", + name: " ", + staff_id: " ", + working_area: " ", + department: " ", + monthly_hours: " ", + hourly_wage: " ", +) = [ + #set text(size: 11pt) + #pad(left: 2.5mm, grid( + columns: (1fr, 92mm), + rows: 6mm, + + [], + align(right)[ + *Monat / Jahr:* #h(6mm) + #_underlined(align(center)[ + #box(width: 21mm)[#month] / #box(width: 21mm)[#year] + ]) + ], + + [*Name, Vorname des/r Beschäftigten:*], + _underlined(box(width: 100%, name)), + + [*Personalnummer:*], + _underlined[ + #box(width: 1fr)[#staff_id] + #h(8mm) + *GF:* #h(1mm) + #_checkbox(working_area == areas.Großforschung) + #h(8mm) + *UB:* #h(1mm) + #_checkbox(working_area == areas.Unibereich) + #h(8mm) + ], + + [*OE:*], // Institut / Organisationseinheit + _underlined(box(width: 100%)[#department]), + + [*Vertraglich vereinbarte Arbeitszeit:*], + [ + #_underlined(align(center)[ + #h(4mm) + #box(width: 10mm)[#monthly_hours] + Std. + #h(4mm) + ]) + #h(1fr) + *Stundensatz:* + #h(4mm) + #_underlined(align(center)[ + #box(width: 18mm)[#hourly_wage] + *€* + ]) + ] + )) +] + +#let _log(..entries) = { + set text(size: 10pt) + table( + columns: (1fr, 23.3mm, 23.3mm, 23.3mm, 23.3mm, 23.3mm), + rows: array.range(24).map(_ => 5.05mm), + align: center + horizon, + stroke: _kit_stroke, + inset: 1mm, + table.header( + table.cell(rowspan: 2)[ + *Tätigkeit* \ + *(Stichwort, Projekt)* + ], + [*Datum*], + [*Beginn*], + [*Ende*], + [*Pause*], + [*Arbeitszeit#super[1]*], + [*(tt.mm.jj)*], + [*(hh:mm)*], + [*(hh:mm)*], + [*(hh:mm)*], + [*(hh:mm)*], + ), + ..entries.pos() + ) +} + +#let _summary( + holiday: [], + total: [], + monthly_hours: [], + carry_prev_month: [], + carry_next_month: [], +) = { + set text(size: 10pt) + align(right, table( + columns: (54mm, 23.3mm), + rows: 5.05mm, + align: center + horizon, + stroke: _kit_stroke, + inset: 1mm, + [*Urlaub anteilig:*], [#holiday], + [*Summe:*], [#total], + [*monatliche Soll-Arbeitszeit:*], [#monthly_hours], + [*Übertrag vom Vormonat:*], [#carry_prev_month], + [*Übertrag in den Folgemonat:*], [#carry_next_month], + )) +} + +#let _footer() = pad(left: 2.5mm)[ + #v(3.5mm) + #grid( + columns: (1fr, 77.5mm), + column-gutter: 6.5mm, + row-gutter: (12mm, 3mm), + [Ich bestätige die Richtigkeit der Angaben:], + [Geprüft:], + pad(left: -2.5mm, line(length: 100%, stroke: stroke(thickness: _kit_stroke, dash: "densely-dotted"))), + pad(left: -2.5mm, line(length: 100%, stroke: stroke(thickness: _kit_stroke, dash: "densely-dotted"))), + [Datum, Unterschrift Beschäftigte/r], + [Datum, Unterschrift Dienstvorgesetzte/r], + ) + + + #v(5.5mm) + + #set text(size: 10pt) + Nach *§ 17 Mindestlohngesetz (MiLoG)* müssen für geringfügig entlohnte und kurzfristig beschäftigte + Arbeitnehmer/innen u.a. Beginn, Ende und Dauer der täglichen Arbeitszeit aufgezeichnet und für Kon- + trollzwecke mindestens zwei Jahre am Ort der Beschäftigung aufbewahrt werden. + + #v(12.5mm) + #line(length: 51mm, stroke: _kit_stroke) + #v(1mm) + + #set text(size: 9pt) + #super[1] Summe in vollen Stunden und Minuten ohne Pause (Std:Min); bei Abwesenheit können auch folgende Kürzel + eingetragen werden: U=Urlaub, K=Krankheit, F=Feiertag, S=Sonstiges +] + +////////// +// Util // +////////// + +#let _pad_int(n, char: "0", width: 2) = { + let s = str(n) + for _ in array.range(width - s.clusters().len()) { char } + s +} + +#let _parse_duration(s) = { + let matched = s.match(regex("^(-?)([0-9]+):([0-5][0-9])$")) + assert(matched != none, message: "invalid duration or time: " + s) + let groups = matched.captures + let sign = if groups.at(0) == "-" { -1 } else { 1 } + let h = int(groups.at(1)) + let m = int(groups.at(2)) + sign * (h * 60 + m) +} + +#let _fmt_duration(mins) = { + if mins < 0 { + "-" + mins = -mins + } + let h = int(mins / 60) + let m = mins - h * 60 + _pad_int(h, char: "0", width: 2) + ":" + _pad_int(m, char: "0", width: 2) +} + +//////////////// +// Validation // +//////////////// + +#let _assert_entry(row, entry, condition, message) = { + message = "row " + str(row) + " (day " + str(entry.day) + "): " + message + assert(condition, message: message) +} + +#let _check_entry(row, entry) = { + let e = entry + _assert_entry(row, e, e.start <= e.end, "start must be before end") + _assert_entry(row, e, e.rest <= e.end - e.start, "rest too long") + + // I think the previous two checks should make it impossible for this assert + // to fail, but just to be careful... + _assert_entry(row, e, e.duration >= 0, "duration must be positive") +} + +////////////////// +// Entry points // +////////////////// + +#let timesheet_empty() = _frame[ + #_header() + #_log() + #_summary() + #_footer() +] + +#let entry( + task, + day, + start, + end, + rest: "0:00", + note: none, +) = { + assert(type(day) == int) + assert(note == none or notes.values().contains(note)) + + start = _parse_duration(start) + end = _parse_duration(end) + rest = _parse_duration(rest) + + ( + task: task, + day: day, + start: start, + end: end, + rest: rest, + duration: end - start - rest, + note: note, + ) +} + +#let timesheet( + name: "Name, Vorname", + staff_id: 1234567, + department: "Institut für Informatik", + working_area: none, + monthly_hours: 40, + hourly_wage: [14.09], + validate: true, + carry_prev_month: "00:00", + year: 2024, + month: 1, + ..entries, +) = { + assert(working_area == none or areas.values().contains(working_area)) + assert(type(monthly_hours) == int) + assert(type(year) == int) + assert(type(month) == int) + + carry_prev_month = _parse_duration(carry_prev_month) + entries = entries.pos() + + let monthly = monthly_hours * 60 + let holiday = entries.filter(e => e.note == notes.Urlaub).map(e => e.duration).sum(default: 0) + let total = entries.map(e => e.duration).sum(default: 0) + let carry_next_month = carry_prev_month + total - monthly + + if validate { + for (row, entry) in entries.enumerate(start: 1) { + _check_entry(row, entry) + } + } + + let rows = entries.map(e => ( + e.task, + datetime(year: year, month: month, day: e.day).display("[day].[month].[year]"), + _fmt_duration(e.start), + _fmt_duration(e.end), + _fmt_duration(e.rest), + { + _fmt_duration(e.duration) + if e.note != none { " "; e.note } + }, + )) + + _frame[ + #_header( + year: year, + month: month, + name: name, + staff_id: staff_id, + working_area: working_area, + department: department, + monthly_hours: monthly_hours, + hourly_wage: hourly_wage, + ) + #_log(..rows.flatten()) + #_summary( + holiday: _fmt_duration(holiday), + total: _fmt_duration(total), + monthly_hours: _fmt_duration(monthly), + carry_prev_month: _fmt_duration(carry_prev_month), + carry_next_month: _fmt_duration(carry_next_month), + ) + #_footer() + ] +}