From 38322a64cf7e3483d38357a5744d38b4edda3daf Mon Sep 17 00:00:00 2001 From: tom_trgr Date: Fri, 6 Feb 2026 17:52:57 +0100 Subject: [PATCH 01/14] fix: time calc errors with only one booking per day + working empty kurzarbeit fixed #74 --- Backend/models/workDay.go | 16 ++++++++++++---- Backend/templates/reportPage.templ | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index e1fec9f..4477323 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -46,11 +46,10 @@ func (d *WorkDay) GetWorktimeAbsence() Absence { // Gets the time as is in the db (with corrected pause times) func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { - if includeKurzarbeit && d.IsKurzArbeit() && len(d.Bookings) > 0 { + if includeKurzarbeit && d.IsKurzArbeit() { //&& len(d.Bookings) > 0 return d.kurzArbeitAbsence.GetWorktime(u, base, true) } - work, pause := calcWorkPause(d.Bookings) - work, pause = correctWorkPause(work, pause) + work, _ := correctWorkPause(getWorkPause(d)) if (d.worktimeAbsece != Absence{}) { work += d.worktimeAbsece.GetWorktime(u, base, false) } @@ -59,7 +58,7 @@ func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) // Gets the corrected pause times based on db entries func (d *WorkDay) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { - work, pause := calcWorkPause(d.Bookings) + work, pause := getWorkPause(d) work, pause = correctWorkPause(work, pause) return pause.Round(time.Minute) } @@ -81,6 +80,15 @@ func (d *WorkDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (w return d.GetWorktime(u, base, includeKurzarbeit), d.GetPausetime(u, base, includeKurzarbeit), d.GetOvertime(u, base, includeKurzarbeit) } +func getWorkPause(d *WorkDay) (work, pause time.Duration) { + //if today calc, else take from db + if helper.IsSameDate(d.Date(), time.Now()) { + return calcWorkPause(d.Bookings) + } else { + return d.workTime, d.pauseTime + } +} + func calcWorkPause(bookings []Booking) (work, pause time.Duration) { var lastBooking Booking for _, b := range bookings { diff --git a/Backend/templates/reportPage.templ b/Backend/templates/reportPage.templ index cf1e1cb..83e9191 100644 --- a/Backend/templates/reportPage.templ +++ b/Backend/templates/reportPage.templ @@ -170,10 +170,12 @@ templ workDayWeekComponent(workDay *models.WorkDay) {
switch { - case !workDay.TimeFrom.Equal(workDay.TimeTo): + case !workDay.IsEmpty(): { workDay.TimeFrom.Format("15:04") } - { workDay.TimeTo.Format("15:04") } + case workDay.IsKurzArbeit(): + Kurzarbeit default:

Keine Anwesenheit

} From 2d8747c971ec164de867183179d782d96a4d927d Mon Sep 17 00:00:00 2001 From: tom_trgr Date: Fri, 6 Feb 2026 17:54:16 +0100 Subject: [PATCH 02/14] fix: refactored sql request for get days + highlight invalid bookings and ignore them for calculation fixed #77 --- Backend/models/booking.go | 4 +- Backend/models/workDay.go | 246 +++++++++++++++---------- Backend/static/css/styles.css | 115 ++++++------ Backend/templates/timeComponents.templ | 9 +- 4 files changed, 220 insertions(+), 154 deletions(-) diff --git a/Backend/models/booking.go b/Backend/models/booking.go index 6704913..6c74f23 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -36,6 +36,7 @@ type Booking struct { Timestamp time.Time `json:"timestamp"` CounterId int `json:"counter_id"` BookingType BookingType `json:"anwesenheit_typ"` + Valid bool `json:"valid"` } type IDatabase interface { @@ -252,12 +253,13 @@ func (b *Booking) Update(nb Booking) { func checkLastBooking(b Booking) bool { var check_in_out int slog.Info("Checking with timestamp:", "timestamp", b.Timestamp.String()) - stmt, err := DB.Prepare((`SELECT check_in_out FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp"::DATE <= $2::DATE ORDER BY "timestamp" DESC LIMIT 1;`)) + stmt, err := DB.Prepare((`SELECT check_in_out FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp" <= $2 ORDER BY "timestamp" DESC LIMIT 1;`)) if err != nil { log.Fatalf("Error preparing query: %v", err) return false } err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out) + slog.Info("Checking last bookings check_in_out", "Check", check_in_out) if err == sql.ErrNoRows { return true } diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 4477323..912e519 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -186,97 +186,148 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { var workSec, pauseSec float64 qStr, err := DB.Prepare(` - WITH all_days AS ( - SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date), - normalized_bookings AS ( - SELECT * - FROM ( - SELECT - a.card_uid, - a.timestamp, - a.timestamp::DATE AS work_date, - a.check_in_out, - a.counter_id, - a.anwesenheit_typ, - sat.anwesenheit_name AS anwesenheit_typ_name, - LAG(a.check_in_out) OVER ( - PARTITION BY a.card_uid, a.timestamp::DATE - ORDER BY a.timestamp - ) AS prev_check - FROM anwesenheit a - LEFT JOIN s_anwesenheit_typen sat - ON a.anwesenheit_typ = sat.anwesenheit_id - WHERE a.card_uid = $1 - AND a.timestamp::DATE >= $2 - AND a.timestamp::DATE <= $3 - ) t - WHERE prev_check IS NULL OR prev_check <> check_in_out - ), - ordered_bookings AS ( - SELECT - *, - LAG(timestamp) OVER ( - PARTITION BY card_uid, work_date - ORDER BY timestamp - ) AS prev_timestamp - FROM normalized_bookings - ) - SELECT - d.work_date, - COALESCE(MIN(b.timestamp), NOW()) AS time_from, - COALESCE(MAX(b.timestamp), NOW()) AS time_to, - COALESCE( - EXTRACT(EPOCH FROM SUM( - CASE - WHEN b.prev_check IN (1, 3) AND b.check_in_out IN (2, 4, 254) - THEN b.timestamp - b.prev_timestamp - ELSE INTERVAL '0' - END - )), 0 - ) AS total_work_seconds, - COALESCE( - EXTRACT(EPOCH FROM SUM( - CASE - WHEN b.prev_check IN (2, 4, 254) AND b.check_in_out IN (1, 3) - THEN b.timestamp - b.prev_timestamp - ELSE INTERVAL '0' - END - )), 0 - ) AS total_pause_seconds, - COALESCE(jsonb_agg(jsonb_build_object( - 'check_in_out', b.check_in_out, - 'timestamp', b.timestamp, - 'counter_id', b.counter_id, - 'anwesenheit_typ', b.anwesenheit_typ, - 'anwesenheit_typ', jsonb_build_object( - 'anwesenheit_id', b.anwesenheit_typ, - 'anwesenheit_name', b.anwesenheit_typ_name - ) - ) ORDER BY b.timestamp), '[]'::jsonb) AS bookings - FROM all_days d - LEFT JOIN ordered_bookings b ON d.work_date = b.work_date - GROUP BY d.work_date - ORDER BY d.work_date ASC;`) + WITH + all_days AS ( + SELECT + generate_series( + $2 ::DATE, + $3 ::DATE - INTERVAL '1 day', + INTERVAL '1 day' + )::DATE AS work_date + ), + all_bookings AS ( + SELECT + a.card_uid, + a.timestamp, + a.timestamp::DATE AS work_date, + a.check_in_out, + a.counter_id, + a.anwesenheit_typ, + sat.anwesenheit_name AS anwesenheit_typ_name, + LAG(a.check_in_out) OVER ( + PARTITION BY + a.card_uid, + a.timestamp::DATE + ORDER BY + a.timestamp + ) AS prev_check, + LAG(a.timestamp) OVER ( + PARTITION BY + a.card_uid, + a.timestamp::DATE + ORDER BY + a.timestamp + ) AS prev_timestamp + FROM + anwesenheit a + LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id + WHERE + a.card_uid = $1 + AND a.timestamp::DATE >= $2::DATE + AND a.timestamp::DATE <= $3::DATE + ), + normalized_bookings AS ( + SELECT + * + FROM + all_bookings + WHERE + prev_check IS NULL + OR prev_check <> check_in_out + ) +SELECT + d.work_date, + COALESCE(MIN(b.timestamp), NOW()) AS time_from, + COALESCE(MAX(b.timestamp), NOW()) AS time_to, + EXTRACT( + EPOCH + FROM + SUM( + CASE + WHEN b.prev_check IN (1, 3) + AND b.check_in_out IN (2, 4, 254) THEN b.timestamp - b.prev_timestamp + ELSE INTERVAL '0' + END + ) + ) AS total_work_seconds, + EXTRACT( + EPOCH + FROM + SUM( + CASE + WHEN b.prev_check IN (2, 4, 254) + AND b.check_in_out IN (1, 3) THEN b.timestamp - b.prev_timestamp + ELSE INTERVAL '0' + END + ) + ) AS total_pause_seconds, + jsonb_agg( + jsonb_build_object( + 'check_in_out', + b.check_in_out, + 'valid', + coalesce(b.check_in_out != b.prev_check, true), + 'timestamp', + b.timestamp, + 'counter_id', + b.counter_id, + 'anwesenheit_typ', + jsonb_build_object( + 'anwesenheit_id', + b.anwesenheit_typ, + 'anwesenheit_name', + b.anwesenheit_typ_name + ) + ) + ORDER BY + b.timestamp + ) FILTER ( + WHERE + b.card_uid IS NOT NULL + ) AS bookings +FROM + all_days d + LEFT JOIN all_bookings b ON b.work_date = d.work_date +GROUP BY + d.work_date; + `) // qStr, err := DB.Prepare(` // WITH all_days AS ( // SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date), - // ordered_bookings AS ( - // SELECT - // a.timestamp::DATE AS work_date, - // a.timestamp, - // a.check_in_out, - // a.counter_id, - // a.anwesenheit_typ, - // sat.anwesenheit_name AS anwesenheit_typ_name, - // LAG(a.timestamp) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_timestamp, - // LAG(a.check_in_out) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_check - // FROM anwesenheit a - // LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id - // WHERE a.card_uid = $1 - // AND a.timestamp::DATE >= $2 - // AND a.timestamp::DATE <= $3 - // ) + // normalized_bookings AS ( + // SELECT * + // FROM ( + // SELECT + // a.card_uid, + // a.timestamp, + // a.timestamp::DATE AS work_date, + // a.check_in_out, + // a.counter_id, + // a.anwesenheit_typ, + // sat.anwesenheit_name AS anwesenheit_typ_name, + // LAG(a.check_in_out) OVER ( + // PARTITION BY a.card_uid, a.timestamp::DATE + // ORDER BY a.timestamp + // ) AS prev_check + // FROM anwesenheit a + // LEFT JOIN s_anwesenheit_typen sat + // ON a.anwesenheit_typ = sat.anwesenheit_id + // WHERE a.card_uid = $1 + // AND a.timestamp::DATE >= $2 + // AND a.timestamp::DATE <= $3 + // ) t + // WHERE prev_check IS NULL OR prev_check <> check_in_out + // ), + // ordered_bookings AS ( + // SELECT + // *, + // LAG(timestamp) OVER ( + // PARTITION BY card_uid, work_date + // ORDER BY timestamp + // ) AS prev_timestamp + // FROM normalized_bookings + // ) // SELECT // d.work_date, // COALESCE(MIN(b.timestamp), NOW()) AS time_from, @@ -330,26 +381,29 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { var workDay WorkDay var bookings []byte if err := rows.Scan(&workDay.Day, &workDay.TimeFrom, &workDay.TimeTo, &workSec, &pauseSec, &bookings); err != nil { - log.Println("Error scanning row!", err) + slog.Error("Error scanning row!", "Error", err) return workDays } workDay.workTime = time.Duration(workSec * float64(time.Second)) workDay.pauseTime = time.Duration(pauseSec * float64(time.Second)) - err = json.Unmarshal(bookings, &workDay.Bookings) - if err != nil { - log.Println("Error parsing bookings JSON!", err) - return nil + if bookings != nil { + err = json.Unmarshal(bookings, &workDay.Bookings) + if err != nil { + slog.Error("Error parsing bookings JSON!", "Error", err, "Json", bookings) + return nil + } } + // better empty day handling - if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 { - workDay.Bookings = []Booking{} - } + // if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 { + // workDay.Bookings = []Booking{} + // } if len(workDay.Bookings) >= 1 || !helper.IsWeekend(workDay.Date()) { workDays = append(workDays, workDay) } } if err = rows.Err(); err != nil { - log.Println("Error in workday rows!", err) + slog.Error("Error in workday rows!", "Error", err) return workDays } return workDays diff --git a/Backend/static/css/styles.css b/Backend/static/css/styles.css index 4b2dd1d..553198f 100644 --- a/Backend/static/css/styles.css +++ b/Backend/static/css/styles.css @@ -205,9 +205,15 @@ .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1/2 * 100%); } + .top-2 { + top: calc(var(--spacing) * 2); + } .top-2\.5 { top: calc(var(--spacing) * 2.5); } @@ -217,9 +223,15 @@ .right-1 { right: calc(var(--spacing) * 1); } + .right-2 { + right: calc(var(--spacing) * 2); + } .right-2\.5 { right: calc(var(--spacing) * 2.5); } + .left-1 { + left: calc(var(--spacing) * 1); + } .left-1\/2 { left: calc(1/2 * 100%); } @@ -350,6 +362,9 @@ .block { display: block; } + .contents { + display: contents; + } .flex { display: flex; } @@ -383,6 +398,9 @@ .h-2 { height: calc(var(--spacing) * 2); } + .h-3 { + height: calc(var(--spacing) * 3); + } .h-3\.5 { height: calc(var(--spacing) * 3.5); } @@ -407,6 +425,9 @@ .w-2 { width: calc(var(--spacing) * 2); } + .w-3 { + width: calc(var(--spacing) * 3); + } .w-3\.5 { width: calc(var(--spacing) * 3.5); } @@ -416,6 +437,9 @@ .w-5 { width: calc(var(--spacing) * 5); } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-9\/10 { width: calc(9/10 * 100%); } @@ -428,6 +452,9 @@ .w-full { width: 100%; } + .flex-shrink { + flex-shrink: 1; + } .flex-shrink-0 { flex-shrink: 0; } @@ -443,10 +470,21 @@ .basis-\[content\] { flex-basis: content; } + .border-collapse { + border-collapse: collapse; + } + .-translate-x-1 { + --tw-translate-x: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -457,33 +495,21 @@ .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .scroll-m-2 { scroll-margin: calc(var(--spacing) * 2); } .appearance-none { appearance: none; } - .break-after-page { - break-after: page; - } - .auto-rows-min { - grid-auto-rows: min-content; - } .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } - .grid-cols-\[3fr_2fr_2fr_2fr_3fr_3fr_3fr\] { - grid-template-columns: 3fr 2fr 2fr 2fr 3fr 3fr 3fr; - } - .grid-cols-subgrid { - grid-template-columns: subgrid; - } - .grid-rows-6 { - grid-template-rows: repeat(6, minmax(0, 1fr)); - } .flex-col { flex-direction: column; } @@ -534,11 +560,6 @@ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } - .divide-neutral-300 { - :where(& > :not(:last-child)) { - border-color: var(--color-neutral-300); - } - } .justify-self-end { justify-self: flex-end; } @@ -565,18 +586,10 @@ border-style: var(--tw-border-style); border-width: 0px; } - .border-r-0 { - border-right-style: var(--tw-border-style); - border-right-width: 0px; - } .border-r-1 { border-right-style: var(--tw-border-style); border-right-width: 1px; } - .border-b-0 { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 0px; - } .border-dashed { --tw-border-style: dashed; border-style: dashed; @@ -587,9 +600,6 @@ .border-neutral-500 { border-color: var(--color-neutral-500); } - .border-neutral-600 { - border-color: var(--color-neutral-600); - } .border-slate-800 { border-color: var(--color-slate-800); } @@ -614,15 +624,15 @@ .bg-red-600 { background-color: var(--color-red-600); } + .mask-repeat { + mask-repeat: repeat; + } .p-1 { padding: calc(var(--spacing) * 1); } .p-2 { padding: calc(var(--spacing) * 2); } - .p-8 { - padding: calc(var(--spacing) * 8); - } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -635,10 +645,6 @@ .text-center { text-align: center; } - .text-2xl { - font-size: var(--text-2xl); - line-height: var(--tw-leading, var(--text-2xl--line-height)); - } .text-sm { font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); @@ -654,15 +660,15 @@ .whitespace-nowrap { white-space: nowrap; } + .\!text-red-500 { + color: var(--color-red-500) !important; + } .text-accent { color: var(--color-accent); } .text-black { color: var(--color-black); } - .text-neutral-300 { - color: var(--color-neutral-300); - } .text-neutral-500 { color: var(--color-neutral-500); } @@ -690,9 +696,16 @@ .uppercase { text-transform: uppercase; } + .underline { + text-decoration-line: underline; + } .opacity-0 { opacity: 0%; } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -719,18 +732,6 @@ -webkit-user-select: none; user-select: none; } - .\*\:text-center { - :is(& > *) { - text-align: center; - } - } - .\*\:not-print\:p-2 { - :is(& > *) { - @media not print { - padding: calc(var(--spacing) * 2); - } - } - } .group-hover\:text-black { &:is(:where(.group):hover *) { @media (hover: hover) { @@ -1024,7 +1025,7 @@ border-width: 1px; border-color: var(--color-neutral-800); transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; - transition-timing-function: var( --tw-ease, var(--default-transition-timing-function) ); + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } input.btn, select.btn { @@ -1138,6 +1139,11 @@ syntax: "*"; inherits: false; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1210,6 +1216,7 @@ --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-font-weight: initial; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/Backend/templates/timeComponents.templ b/Backend/templates/timeComponents.templ index 7f2d066..e551102 100644 --- a/Backend/templates/timeComponents.templ +++ b/Backend/templates/timeComponents.templ @@ -77,10 +77,13 @@ templ newBookingComponent(d models.IWorkDay) { templ bookingComponent(booking models.Booking) {
-

+

{ booking.Timestamp.Format("15:04") } { booking.GetBookingType() } + if !booking.Valid { + fehlerhafte Buchung, wird nicht zur Berechnung verwendet! + }

if booking.IsSubmittedAndChecked() {

submitted

@@ -89,10 +92,10 @@ templ bookingComponent(booking models.Booking) { } templ workdayComponent(workDay *models.WorkDay) { - if len(workDay.Bookings) < 1 { + if workDay.IsEmpty() && !workDay.IsKurzArbeit() {

Keine Buchung gefunden. Bitte Arbeitsstunden oder Grund der Abwesenheit eingeben!

} else { - if workDay.IsKurzArbeit() && len(workDay.Bookings) > 0 { + if workDay.IsKurzArbeit() { @absenceComponent(workDay.GetKurzArbeit(), true) } for _, booking := range workDay.Bookings { From 8911165c4b68998692a220bc5a7c5829e88aca49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Mon, 9 Feb 2026 00:32:00 +0100 Subject: [PATCH 03/14] feat: locking days, if they are submitted and accepted fixed #76 --- Backend/models/absence.go | 23 +++++++++++ Backend/models/booking.go | 21 ---------- Backend/models/compoundDay.go | 11 +++++ Backend/models/iworkday.go | 1 + Backend/models/publicHoliday.go | 5 +++ Backend/models/user.go | 40 ++++++++++++++++-- Backend/models/workDay.go | 38 +++++++++++++++++ Backend/models/workWeek.go | 43 ++++++++++++++------ Backend/static/css/styles.css | 53 +++++++++++++++++++++--- Backend/templates/reportPage.templ | 6 +++ Backend/templates/timeComponents.templ | 29 +++++++------ Backend/templates/timePage.templ | 7 +++- DBB/initdb/01_create_user.sh | 56 -------------------------- 13 files changed, 218 insertions(+), 115 deletions(-) delete mode 100755 DBB/initdb/01_create_user.sh diff --git a/Backend/models/absence.go b/Backend/models/absence.go index 7c99e76..21150f6 100644 --- a/Backend/models/absence.go +++ b/Backend/models/absence.go @@ -13,8 +13,10 @@ package models // the absence data is based on the entries in the "abwesenheit" database table import ( + "database/sql" "encoding/json" "log" + "log/slog" "time" ) @@ -295,3 +297,24 @@ func (a *Absence) Delete() error { _, err = qStr.Exec(a.CounterId) return err } + +func (a *Absence) IsSubmittedAndAccepted() bool { + qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(abwesenheiten) AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains + if err != nil { + slog.Warn("Error when preparing SQL Statement", "error", err) + return false + } + defer qStr.Close() + var isSubmittedAndChecked bool = false + + err = qStr.QueryRow(a.CounterId, a.Date()).Scan(&isSubmittedAndChecked) + if err == sql.ErrNoRows { + // No rows found ==> not even submitted + return false + } + + if err != nil { + slog.Warn("Unexpected error when executing SQL Statement", "error", err) + } + return isSubmittedAndChecked +} diff --git a/Backend/models/booking.go b/Backend/models/booking.go index 6c74f23..d406de4 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -95,27 +95,6 @@ func (b *Booking) Verify() bool { return true } -func (b *Booking) IsSubmittedAndChecked() bool { - qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(anwesenheiten);`) - if err != nil { - slog.Warn("Error when preparing SQL Statement", "error", err) - return false - } - defer qStr.Close() - var isSubmittedAndChecked bool = false - - err = qStr.QueryRow(b.CounterId).Scan(&isSubmittedAndChecked) - if err == sql.ErrNoRows { - // No rows found ==> not even submitted - return false - } - - if err != nil { - slog.Warn("Unexpected error when executing SQL Statement", "error", err) - } - return isSubmittedAndChecked -} - func (b *Booking) Insert() error { if !checkLastBooking(*b) { return SameBookingError{} diff --git a/Backend/models/compoundDay.go b/Backend/models/compoundDay.go index 323cea7..c3aac27 100644 --- a/Backend/models/compoundDay.go +++ b/Backend/models/compoundDay.go @@ -17,6 +17,17 @@ type CompoundDay struct { DayParts []IWorkDay } +// IsSubmittedAndAccepted implements IWorkDay. +func (c *CompoundDay) IsSubmittedAndAccepted() bool { + var isSubmittedAndAccepted = true + for _, day := range c.DayParts { + _isSubmittedAndAccepted := day.IsSubmittedAndAccepted() + isSubmittedAndAccepted = isSubmittedAndAccepted && _isSubmittedAndAccepted + slog.Info("Result from IsSubmittedCheck", "Result", _isSubmittedAndAccepted, "compount", day.ToString()) + } + return isSubmittedAndAccepted +} + func NewCompondDay(date time.Time, dayParts ...IWorkDay) *CompoundDay { return &CompoundDay{Day: date, DayParts: dayParts} } diff --git a/Backend/models/iworkday.go b/Backend/models/iworkday.go index 2f7d038..3f96166 100644 --- a/Backend/models/iworkday.go +++ b/Backend/models/iworkday.go @@ -23,6 +23,7 @@ type IWorkDay interface { GetTimes(User, WorktimeBase, bool) (work, pause, overtime time.Duration) GetOvertime(User, WorktimeBase, bool) time.Duration IsEmpty() bool + IsSubmittedAndAccepted() bool } type DayType int diff --git a/Backend/models/publicHoliday.go b/Backend/models/publicHoliday.go index 1a6707a..bc1b11d 100644 --- a/Backend/models/publicHoliday.go +++ b/Backend/models/publicHoliday.go @@ -19,6 +19,11 @@ type PublicHoliday struct { worktime int8 } +// IsSubmittedAndAccepted implements IWorkDay. +func (p *PublicHoliday) IsSubmittedAndAccepted() bool { + return true +} + // IsEmpty implements [IWorkDay]. func (p *PublicHoliday) IsEmpty() bool { return false diff --git a/Backend/models/user.go b/Backend/models/user.go index 605687c..5389c33 100644 --- a/Backend/models/user.go +++ b/Backend/models/user.go @@ -292,10 +292,42 @@ func (u *User) GetNextWeek() WorkWeek { func (u *User) GetLastWorkWeekSubmission() time.Time { var lastSub time.Time qStr, err := DB.Prepare(` - SELECT COALESCE( - (SELECT woche_start + INTERVAL '1 week' FROM wochen_report WHERE personal_nummer = $1 ORDER BY woche_start DESC LIMIT 1), - (SELECT timestamp FROM anwesenheit WHERE card_uid = $2 ORDER BY timestamp LIMIT 1) - ) AS letzte_buchung; + SELECT new_week +FROM ( + -- Highest priority + SELECT + woche_start AS new_week, + 1 AS priority + FROM wochen_report + WHERE personal_nummer = $1 + AND bestaetigt IS NULL + + UNION ALL + + -- Fallback if #1 returns nothing + SELECT + woche_start + INTERVAL '1 week' AS new_week, + 2 AS priority + FROM wochen_report wo + WHERE personal_nummer = $1 + AND NOT EXISTS ( + SELECT 1 + FROM wochen_report wi + WHERE wi.woche_start = wo.woche_start + INTERVAL '1 week' + AND wi.personal_nummer = wo.personal_nummer + ) + + UNION ALL + + -- Final fallback + SELECT + timestamp AS new_week, + 3 AS priority + FROM anwesenheit + WHERE card_uid = $2 +) t +ORDER BY priority, new_week +LIMIT 1; `) if err != nil { slog.Debug("Error preparing query statement.", "error", err) diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 912e519..3e9eb05 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -7,12 +7,15 @@ package models import ( "arbeitszeitmessung/helper" + "database/sql" "encoding/json" "fmt" "log" "log/slog" "sort" "time" + + "github.com/lib/pq" ) type WorkDay struct { @@ -425,3 +428,38 @@ func (d *WorkDay) GetDayProgress(u User) int8 { progress := (workTime.Seconds() / u.ArbeitszeitProTag().Seconds()) * 100 return int8(progress) } + +func (d *WorkDay) IsSubmittedAndAccepted() bool { + var isKurzArbeitAccepted bool + if d.IsKurzArbeit() { + isKurzArbeitAccepted = d.kurzArbeitAbsence.IsSubmittedAndAccepted() + } + + if d.IsEmpty() { + return isKurzArbeitAccepted + } + + qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE anwesenheiten @> $1 AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains + if err != nil { + slog.Warn("Error when preparing SQL Statement", "error", err) + return false + } + + defer qStr.Close() + var isSubmittedAndChecked bool = false + + var bookingsIds []int + for _, booking := range d.Bookings { + bookingsIds = append(bookingsIds, booking.CounterId) + } + + err = qStr.QueryRow(pq.Array(bookingsIds), d.Date()).Scan(&isSubmittedAndChecked) + if err == sql.ErrNoRows { + return false + } + + if err != nil { + slog.Warn("Unexpected error when executing SQL Statement", "error", err, "BookingsIds", bookingsIds) + } + return isSubmittedAndChecked +} diff --git a/Backend/models/workWeek.go b/Backend/models/workWeek.go index ef7d284..1dfcbd3 100644 --- a/Backend/models/workWeek.go +++ b/Backend/models/workWeek.go @@ -34,6 +34,7 @@ type WeekStatus int8 const ( WeekStatusNone WeekStatus = iota + WeekStatusCorrected WeekStatusSent WeekStatusAccepted WeekStatusDifferences @@ -86,25 +87,31 @@ func (w *WorkWeek) CheckStatus() WeekStatus { log.Println("Cannot access Database!") return w.Status } - qStr, err := DB.Prepare(`SELECT bestaetigt FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`) + qStr, err := DB.Prepare(`SELECT bestaetigt, id FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`) if err != nil { log.Println("Error preparing SQL statement", err) return w.Status } + defer qStr.Close() - var beastatigt bool - err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt) + var beastatigt sql.NullBool + err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt, &w.Id) if err == sql.ErrNoRows { return w.Status } + slog.Info("Bestätigt query res", "Best", beastatigt, "week", w.Id) if err != nil { log.Println("Error querying database", err) return w.Status } - if beastatigt { + switch { + case beastatigt.Bool: w.Status = WeekStatusAccepted - } else { + case beastatigt.Valid: w.Status = WeekStatusSent + default: + w.Status = WeekStatusCorrected + } return w.Status } @@ -206,23 +213,33 @@ func (w *WorkWeek) SendWeek() error { return ErrRunningWeek } - if w.CheckStatus() != WeekStatusNone { - qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = FALSE, arbeitszeit = make_interval(secs => $3::numeric / 1000000000), ueberstunden = make_interval(secs => $4::numeric / 1000000000), anwesenheiten=$5, abwesenheiten=$6 WHERE personal_nummer = $1 AND woche_start = $2;`) - if err != nil { - slog.Warn("Error preparing SQL statement", "error", err) - return err - } - } else { + switch w.CheckStatus() { + case WeekStatusNone: qStr, err = DB.Prepare(`INSERT INTO wochen_report (personal_nummer, woche_start, arbeitszeit, ueberstunden, anwesenheiten, abwesenheiten) VALUES ($1, $2, make_interval(secs => $3::numeric / 1000000000), make_interval(secs => $4::numeric / 1000000000), $5, $6);`) if err != nil { slog.Warn("Error preparing SQL statement", "error", err) return err } + + case WeekStatusCorrected: + qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = FALSE, arbeitszeit = make_interval(secs => $3::numeric / 1000000000), ueberstunden = make_interval(secs => $4::numeric / 1000000000), anwesenheiten=$5, abwesenheiten=$6 WHERE personal_nummer = $1 AND woche_start = $2;`) + if err != nil { + slog.Warn("Error preparing SQL statement", "error", err) + return err + } + + case WeekStatusSent, WeekStatusAccepted: + qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = null WHERE personal_nummer = $1 AND woche_start = $2 AND ($3::numeric IS NULL OR TRUE) AND ($4::numeric IS NULL OR TRUE) AND ($5::int[] IS NULL OR TRUE) AND ($6::int[] IS NULL OR TRUE);`) + if err != nil { + slog.Warn("Error preparing SQL statement", "error", err) + return err + } + } _, err = qStr.Exec(w.User.PersonalNummer, w.WeekStart, int64(w.Worktime), int64(w.Overtime), pq.Array(anwBookings), pq.Array(awBookings)) if err != nil { - log.Println("Error executing query!", err) + slog.Error("Error executing query!", "error", err) return err } return nil diff --git a/Backend/static/css/styles.css b/Backend/static/css/styles.css index 553198f..b26c407 100644 --- a/Backend/static/css/styles.css +++ b/Backend/static/css/styles.css @@ -20,7 +20,6 @@ --color-neutral-300: oklch(87% 0 0); --color-neutral-400: oklch(70.8% 0 0); --color-neutral-500: oklch(55.6% 0 0); - --color-neutral-600: oklch(43.9% 0 0); --color-neutral-700: oklch(37.1% 0 0); --color-neutral-800: oklch(26.9% 0 0); --color-black: #000; @@ -30,8 +29,6 @@ --text-sm--line-height: calc(1.25 / 0.875); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); - --text-2xl: 1.5rem; - --text-2xl--line-height: calc(2 / 1.5); --font-weight-bold: 700; --radius-md: 0.375rem; --default-transition-duration: 150ms; @@ -253,6 +250,12 @@ .-my-1 { margin-block: calc(var(--spacing) * -1); } + .my-2 { + margin-block: calc(var(--spacing) * 2); + } + .my-4 { + margin-block: calc(var(--spacing) * 4); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -320,6 +323,32 @@ mask-size: 100% 100%; --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M7.616 20q-.672 0-1.144-.472T6 18.385V6H5V5h4v-.77h6V5h4v1h-1v12.385q0 .69-.462 1.153T16.384 20zM17 6H7v12.385q0 .269.173.442t.443.173h8.769q.23 0 .423-.192t.192-.424zM9.808 17h1V8h-1zm3.384 0h1V8h-1zM7 6v13z'/%3E%3C/svg%3E"); } + .icon-\[material-symbols-light--edit-calendar-rounded\] { + display: inline-block; + width: 1.25em; + height: 1.25em; + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M5.616 21q-.691 0-1.153-.462T4 19.385V6.615q0-.69.463-1.152T5.616 5h1.769V3.308q0-.23.155-.384q.156-.155.386-.155t.383.155t.153.384V5h7.154V3.27q0-.213.143-.357q.144-.144.357-.144t.356.144t.144.356V5h1.769q.69 0 1.153.463T20 6.616v4.601q0 .213-.144.356t-.357.144t-.356-.144t-.143-.356v-.602H5v8.77q0 .23.192.423t.423.192h5.731q.213 0 .357.144t.143.357t-.143.356t-.357.143zm8.615-.808V19.12q0-.153.056-.296q.055-.144.186-.275l5.09-5.065q.149-.13.306-.19t.315-.062q.172 0 .338.064q.166.065.301.194l.925.944q.123.148.188.308q.064.159.064.319t-.052.322t-.2.31l-5.065 5.066q-.131.13-.275.186q-.143.056-.297.056h-1.073q-.343 0-.575-.232t-.232-.576m5.96-4.177l.925-.956l-.925-.944l-.95.95z'/%3E%3C/svg%3E"); + } + .icon-\[material-symbols-light--lock\] { + display: inline-block; + width: 1.25em; + height: 1.25em; + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M6.616 21q-.667 0-1.141-.475T5 19.386v-8.77q0-.666.475-1.14T6.615 9H8V7q0-1.671 1.165-2.835Q10.329 3 12 3t2.836 1.165T16 7v2h1.385q.666 0 1.14.475t.475 1.14v8.77q0 .666-.475 1.14t-1.14.475zM12 16.5q.633 0 1.066-.434q.434-.433.434-1.066t-.434-1.066T12 13.5t-1.066.434Q10.5 14.367 10.5 15t.434 1.066q.433.434 1.066.434M9 9h6V7q0-1.25-.875-2.125T12 4t-2.125.875T9 7z'/%3E%3C/svg%3E"); + } .icon-\[material-symbols-light--more-time\] { display: inline-block; width: 1.25em; @@ -395,6 +424,18 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } + .size-6 { + width: calc(var(--spacing) * 6); + height: calc(var(--spacing) * 6); + } + .size-8 { + width: calc(var(--spacing) * 8); + height: calc(var(--spacing) * 8); + } + .size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); + } .h-2 { height: calc(var(--spacing) * 2); } @@ -633,6 +674,9 @@ .p-2 { padding: calc(var(--spacing) * 2); } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -660,9 +704,6 @@ .whitespace-nowrap { white-space: nowrap; } - .\!text-red-500 { - color: var(--color-red-500) !important; - } .text-accent { color: var(--color-accent); } diff --git a/Backend/templates/reportPage.templ b/Backend/templates/reportPage.templ index 83e9191..8942fc0 100644 --- a/Backend/templates/reportPage.templ +++ b/Backend/templates/reportPage.templ @@ -47,6 +47,12 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
if !onlyAccept {
+ if week.CheckStatus() == models.WeekStatusCorrected { + +
+ laufende Korrektur +
+ } @statusCheckMark(week.CheckStatus(), models.WeekStatusSent) Gesendet diff --git a/Backend/templates/timeComponents.templ b/Backend/templates/timeComponents.templ index e551102..711db60 100644 --- a/Backend/templates/timeComponents.templ +++ b/Backend/templates/timeComponents.templ @@ -6,16 +6,22 @@ import ( "time" ) -templ changeButtonComponent(id string, workDay bool) { - - +templ changeButtonComponent(id string, workDay bool, disabled bool) { + if disabled { + + } else { + + + } } templ newAbsenceComponent() { @@ -85,9 +91,6 @@ templ bookingComponent(booking models.Booking) { fehlerhafte Buchung, wird nicht zur Berechnung verwendet! }

- if booking.IsSubmittedAndChecked() { -

submitted

- }
} diff --git a/Backend/templates/timePage.templ b/Backend/templates/timePage.templ index 4ac2e6c..0666ab3 100644 --- a/Backend/templates/timePage.templ +++ b/Backend/templates/timePage.templ @@ -142,8 +142,11 @@ templ defaultDayComponent(day models.IWorkDay) {
-
- @changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true) +
+ @changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true, day.IsSubmittedAndAccepted()) + if day.IsSubmittedAndAccepted() { + + }
} diff --git a/DBB/initdb/01_create_user.sh b/DBB/initdb/01_create_user.sh deleted file mode 100755 index 91c3259..0000000 --- a/DBB/initdb/01_create_user.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -set -e # Exit on error - -echo "Creating PostgreSQL user and setting permissions... $POSTGRES_USER for API user $POSTGRES_API_USER" - - - -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE ROLE migrate LOGIN ENCRYPTED PASSWORD '$POSTGRES_PASSWORD'; - GRANT USAGE, CREATE ON SCHEMA public TO migrate; - GRANT CONNECT ON DATABASE arbeitszeitmessung TO migrate; -EOSQL - -# psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - -# GRANT SELECT, INSERT, UPDATE ON anwesenheit, abwesenheit, user_password, wochen_report, s_feiertage TO $POSTGRES_API_USER; -# GRANT DELETE ON abwesenheit TO $POSTGRES_API_USER; -# GRANT SELECT ON s_personal_daten, s_abwesenheit_typen, s_anwesenheit_typen, s_feiertage TO $POSTGRES_API_USER; -# GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $POSTGRES_API_USER; -# EOSQL - -echo "User creation and permissions setup complete!" - - -psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - --- privilege roles -DO \$\$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_base') THEN - CREATE ROLE app_base NOLOGIN; - END IF; -END -\$\$; - --- dynamic login role -DO \$\$ -BEGIN - IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_API_USER') THEN - CREATE ROLE $POSTGRES_API_USER - LOGIN - ENCRYPTED PASSWORD '$POSTGRES_API_PASS'; - END IF; -END -\$\$; - --- grant base privileges -GRANT app_base TO $POSTGRES_API_USER; -GRANT CONNECT ON DATABASE $POSTGRES_DB TO $POSTGRES_API_USER; -GRANT USAGE ON SCHEMA public TO $POSTGRES_API_USER; - -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -EOSQL - -# psql -v ON_ERROR_STOP=1 --username root --dbname arbeitszeitmessung From 46218f9bcaab488c3443584449449e272a98baca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Thu, 12 Feb 2026 16:46:57 +0100 Subject: [PATCH 04/14] fix: weekbased calculation pdf report with this change the time calculations for pdf reports should be better line with the reports send as "week_report" --- Backend/endpoints/pdf-create.go | 235 ++++++++++++++++++----------- Backend/endpoints/team-report.go | 4 +- Backend/endpoints/time.go | 2 +- Backend/helper/system.go | 4 + Backend/helper/time.go | 59 ++++++++ Backend/helper/time_test.go | 95 ++++++++++++ Backend/helper/web.go | 2 +- Backend/main.go | 2 +- Backend/models/absence.go | 2 +- Backend/models/booking.go | 2 +- Backend/models/iworkday.go | 4 +- Backend/models/publicHoliday.go | 2 + Backend/models/user.go | 8 +- Backend/models/workDay.go | 13 +- Backend/models/workWeek.go | 37 ++++- Backend/models/workWeek_test.go | 2 +- Backend/static/css/styles.css | 68 --------- Backend/templates/reportPage.templ | 2 +- 18 files changed, 365 insertions(+), 178 deletions(-) diff --git a/Backend/endpoints/pdf-create.go b/Backend/endpoints/pdf-create.go index b93be94..01efb09 100644 --- a/Backend/endpoints/pdf-create.go +++ b/Backend/endpoints/pdf-create.go @@ -21,66 +21,7 @@ import ( const DE_DATE string = "02.01.2006" const FILE_YEAR_MONTH string = "2006_01" -func convertDaysToTypst(days []models.IWorkDay, u models.User) ([]typstDay, error) { - var typstDays []typstDay - for _, day := range days { - var thisTypstDay typstDay - work, pause, overtime := day.GetTimes(u, models.WorktimeBaseDay, false) - workVirtual := day.GetWorktime(u, models.WorktimeBaseDay, true) - overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2) - thisTypstDay.Date = day.Date().Format(DE_DATE) - thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true) - thisTypstDay.Pausetime = helper.FormatDurationFill(pause, true) - thisTypstDay.Overtime = helper.FormatDurationFill(overtime, true) - thisTypstDay.IsFriday = day.Date().Weekday() == time.Friday - - if workVirtual > work { - thisTypstDay.Kurzarbeit = helper.FormatDurationFill(workVirtual-work, true) - } else { - thisTypstDay.Kurzarbeit = helper.FormatDurationFill(0, true) - } - - thisTypstDay.DayParts = convertDayToTypstDayParts(day, u) - typstDays = append(typstDays, thisTypstDay) - } - return typstDays, nil -} - -func convertDayToTypstDayParts(day models.IWorkDay, user models.User) []typstDayPart { - var typstDayParts []typstDayPart - switch day.Type() { - case models.DayTypeWorkday: - workDay, _ := day.(*models.WorkDay) - for i := 0; i < len(workDay.Bookings); i += 2 { - var typstDayPart typstDayPart - typstDayPart.BookingFrom = workDay.Bookings[i].Timestamp.Format("15:04") - if i+1 < len(workDay.Bookings) { - typstDayPart.BookingTo = workDay.Bookings[i+1].Timestamp.Format("15:04") - } else { - typstDayPart.BookingTo = workDay.Bookings[i].Timestamp.Format("15:04") - } - typstDayPart.WorkType = workDay.Bookings[i].BookingType.Name - typstDayPart.IsWorkDay = true - typstDayParts = append(typstDayParts, typstDayPart) - } - if day.IsKurzArbeit() && len(workDay.Bookings) > 0 { - tsFrom, tsTo := workDay.GenerateKurzArbeitBookings(user) - typstDayParts = append(typstDayParts, typstDayPart{ - BookingFrom: tsFrom.Format("15:04"), - BookingTo: tsTo.Format("15:04"), - WorkType: "Kurzarbeit", - IsWorkDay: true, - }) - } - case models.DayTypeCompound: - for _, c := range day.(*models.CompoundDay).DayParts { - typstDayParts = append(typstDayParts, convertDayToTypstDayParts(c, user)...) - } - default: - typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: day.ToString()}) - } - return typstDayParts -} +const PDF_DIRECTORY = "/home/tom/Code/arbeitszeitmessung/Backend/doc" func PDFCreateController(w http.ResponseWriter, r *http.Request) { helper.RequiresLogin(Session, w, r) @@ -101,14 +42,16 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) { return } - n := 0 - for _, e := range employes { - if user.IsSuperior(e) { - employes[n] = e - n++ + if !helper.IsDebug() { + n := 0 + for _, e := range employes { + if user.IsSuperior(e) { + employes[n] = e + n++ + } } + employes = employes[:n] } - employes = employes[:n] reportData := createReports(employes, startDate) @@ -119,8 +62,9 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) { slog.Warn("Could not create pdf report", slog.Any("Error", err)) w.WriteHeader(http.StatusInternalServerError) } + w.Header().Set("Content-type", "application/pdf") - w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH))) + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s.pdf", startDate.Format(FILE_YEAR_MONTH))) output.WriteTo(w) w.WriteHeader(http.StatusOK) case "download": @@ -131,11 +75,12 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) { } output, err := zipPfd(pdfReports, &reportData) if err != nil { - slog.Warn("Could not create pdf report", slog.Any("Error", err)) + slog.Warn("Could not zip pdf reports", slog.Any("Error", err)) w.WriteHeader(http.StatusInternalServerError) } + w.Header().Set("Content-type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH))) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s.zip", startDate.Format(FILE_YEAR_MONTH))) output.WriteTo(w) w.WriteHeader(http.StatusOK) } @@ -145,6 +90,73 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) { } } +func convertDaysToTypst(days []models.IWorkDay, u models.User, weekbase models.WorktimeBase) ([]typstDay, error) { + var typstDays []typstDay + for i, day := range days { + if !day.IsSubmittedAndAccepted() && !helper.IsDebug() { + continue + } + + var thisTypstDay typstDay + workVirtual, pause, overtime := day.GetTimes(u, weekbase, true) + + if day.Type() != models.DayTypeHoliday { + overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2) + } + thisTypstDay.Date = day.Date().Format(DE_DATE) + thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true) + thisTypstDay.Pausetime = helper.FormatDurationFill(pause, true) + thisTypstDay.Overtime = helper.FormatDurationFill(overtime, true) + thisTypstDay.IsFriday = i == len(days)-1 + + if work := day.GetWorktime(u, weekbase, false); workVirtual > work { + thisTypstDay.Kurzarbeit = helper.FormatDurationFill(workVirtual-work, true) + } else { + thisTypstDay.Kurzarbeit = helper.FormatDurationFill(0, true) + } + + thisTypstDay.DayParts = convertDayToTypstDayParts(day, u, weekbase) + typstDays = append(typstDays, thisTypstDay) + } + return typstDays, nil +} + +func convertDayToTypstDayParts(day models.IWorkDay, user models.User, weekBase models.WorktimeBase) []typstDayPart { + var typstDayParts []typstDayPart + switch day.Type() { + case models.DayTypeWorkday: + workDay, _ := day.(*models.WorkDay) + for i := 0; i < len(workDay.Bookings); i += 2 { + var typstDayPart typstDayPart + typstDayPart.BookingFrom = workDay.Bookings[i].Timestamp.Format("15:04") + if i+1 < len(workDay.Bookings) { + typstDayPart.BookingTo = workDay.Bookings[i+1].Timestamp.Format("15:04") + } else { + typstDayPart.BookingTo = workDay.Bookings[i].Timestamp.Format("15:04") + } + typstDayPart.WorkType = workDay.Bookings[i].BookingType.Name + typstDayPart.IsWorkDay = true + typstDayParts = append(typstDayParts, typstDayPart) + } + if day.IsKurzArbeit() { + tsFrom, tsTo := workDay.GenerateKurzArbeitBookings(user, weekBase) + typstDayParts = append(typstDayParts, typstDayPart{ + BookingFrom: tsFrom.Format("15:04"), + BookingTo: tsTo.Format("15:04"), + WorkType: "Kurzarbeit", + IsWorkDay: true, + }) + } + case models.DayTypeCompound: + for _, c := range day.(*models.CompoundDay).DayParts { + typstDayParts = append(typstDayParts, convertDayToTypstDayParts(c, user, weekBase)...) + } + default: + typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: day.ToString()}) + } + return typstDayParts +} + func createReports(employes []models.User, startDate time.Time) []typstData { startDate = helper.GetFirstOfMonth(startDate) endDate := startDate.AddDate(0, 1, -1) @@ -161,28 +173,67 @@ func createReports(employes []models.User, startDate time.Time) []typstData { } func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) { - publicHolidays, err := models.GetHolidaysFromTo(startDate, endDate) + publicHolidays, _ := models.GetHolidaysFromTo(startDate, endDate) targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)-len(publicHolidays)) - workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false) - - slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours()) + daysThisMonth := helper.GenerateDateRange(startDate, endDate) + mondaysThisMonth := helper.GetMondays(daysThisMonth, false) + var weeks []models.WorkWeek var workHours, kurzarbeitHours time.Duration - for _, day := range workDaysThisMonth { - tmpvirtualHours := day.GetWorktime(employee, models.WorktimeBaseDay, true) - tmpactualHours := day.GetWorktime(employee, models.WorktimeBaseDay, false) - if day.IsKurzArbeit() && tmpvirtualHours > tmpactualHours { - slog.Debug("Adding kurzarbeit to workday", "day", day.Date()) - kurzarbeitHours += tmpvirtualHours - tmpactualHours + for _, monday := range mondaysThisMonth { + var week models.WorkWeek + if monday.After(startDate) { + week = models.NewWorkWeekSimple(employee, monday, true) + } else if startDate.Sub(monday) < time.Hour*24*6 { + week = models.NewWorkWeek(employee, startDate, monday.Add(6*24*time.Hour), true) } - workHours += tmpvirtualHours + workHours += week.WorktimeVirtual + kurzarbeitHours += week.WorktimeVirtual - week.Worktime + weeks = append(weeks, week) } + var typstDays []typstDay + for _, week := range weeks { + weekTypstDays, err := convertDaysToTypst(week.Days, employee, week.WeekBase) + if err != nil { + slog.Error("Error converting days into typst", "error", err) + continue + } + typstDays = append(typstDays, weekTypstDays...) + } + + slog.Info("Weeks for the month", "week len", len(weeks), "week", weeks) + // workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false) + + // var weekbase models.WorktimeBase + // if lenWorkDays(workDaysThisMonth) == helper.GetWorkingDays(startDate, endDate) { + // weekbase = models.WorktimeBaseWeek + // } else { + // weekbase = models.WorktimeBaseDay + // } + + // slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours(), "days", helper.GetWorkingDays(startDate, endDate), "workdays", lenWorkDays(workDaysThisMonth)) + + // var workHours, kurzarbeitHours time.Duration + // for _, day := range workDaysThisMonth { + // tmpvirtualHours := day.GetWorktime(employee, weekbase, true) + // tmpactualHours := day.GetWorktime(employee, weekbase, false) + // if day.IsKurzArbeit() && tmpvirtualHours > tmpactualHours { + // slog.Debug("Adding kurzarbeit to workday", "day", day.Date()) + // kurzarbeitHours += tmpvirtualHours - tmpactualHours + // } + // workHours += tmpvirtualHours + // } worktimeBalance := workHours - targetHoursThisMonth - typstDays, err := convertDaysToTypst(workDaysThisMonth, employee) + // typstDays, err := convertDaysToTypst(workDaysThisMonth, employee, weekbase) + // if err != nil { + // slog.Warn("Failed to convert to days", slog.Any("error", err)) + // return typstData{}, err + // } + + totalOvertime, err := employee.GetReportedOvertime(endDate) if err != nil { - slog.Warn("Failed to convert to days", slog.Any("error", err)) - return typstData{}, err + slog.Error("Cannot retrieve total Overtime", "Error", err) } metadata := typstMetadata{ @@ -191,7 +242,7 @@ func createEmployeReport(employee models.User, startDate, endDate time.Time) (ty Overtime: helper.FormatDurationFill(worktimeBalance, true), WorkTime: helper.FormatDurationFill(workHours, true), Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true), - OvertimeTotal: "", + OvertimeTotal: helper.FormatDurationFill(totalOvertime+worktimeBalance, true), CurrentTimestamp: time.Now().Format("02.01.2006 - 15:04 Uhr"), } return typstData{Meta: metadata, Days: typstDays, FileName: fmt.Sprintf("%s_%s.pdf", startDate.Format(FILE_YEAR_MONTH), employee.Name)}, nil @@ -202,8 +253,7 @@ func renderPDFSingle(data []typstData) (bytes.Buffer, error) { var output bytes.Buffer typstCLI := typst.CLI{ - WorkingDirectory: "/doc/", - // ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"), + WorkingDirectory: PDF_DIRECTORY, } if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil { @@ -230,8 +280,7 @@ func renderPDFMulti(data []typstData) ([]bytes.Buffer, error) { var outputMulti []bytes.Buffer typstRender := typst.CLI{ - WorkingDirectory: "/doc/", - // ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"), + WorkingDirectory: PDF_DIRECTORY, } for _, d := range data { @@ -273,6 +322,16 @@ func zipPfd(pdfReports []bytes.Buffer, reportData *[]typstData) (bytes.Buffer, e return zipOutput, err } +func lenWorkDays(workDays []models.IWorkDay) int { + var lenght int + for _, day := range workDays { + if !day.IsEmpty() || day.IsKurzArbeit() { + lenght += 1 + } + } + return lenght +} + type typstMetadata struct { TimeRange string `json:"time-range"` EmployeeName string `json:"employee-name"` diff --git a/Backend/endpoints/team-report.go b/Backend/endpoints/team-report.go index 1babc7b..482f8fa 100644 --- a/Backend/endpoints/team-report.go +++ b/Backend/endpoints/team-report.go @@ -42,7 +42,7 @@ func submitReport(w http.ResponseWriter, r *http.Request) { return } - workWeek := models.NewWorkWeek(user, weekTs, true) + workWeek := models.NewWorkWeekSimple(user, weekTs, true) switch r.FormValue("method") { case "send": @@ -70,7 +70,7 @@ func showWeeks(w http.ResponseWriter, r *http.Request) { submissionDate := pp.ParseTimestampFallback("submission_date", time.DateOnly, user.GetLastWorkWeekSubmission()) lastSub := helper.GetMonday(submissionDate) - userWeek := models.NewWorkWeek(user, lastSub, true) + userWeek := models.NewWorkWeekSimple(user, lastSub, true) var workWeeks []models.WorkWeek teamMembers, err := user.GetTeamMembers() diff --git a/Backend/endpoints/time.go b/Backend/endpoints/time.go index 09e66f1..883c7b3 100644 --- a/Backend/endpoints/time.go +++ b/Backend/endpoints/time.go @@ -100,7 +100,7 @@ func getBookings(w http.ResponseWriter, r *http.Request) { } aggregatedOvertime += day.GetOvertime(user, models.WorktimeBaseDay, true) } - if reportedOvertime, err := user.GetReportedOvertime(); err == nil { + if reportedOvertime, err := user.GetReportedOvertime(time.Now()); err == nil { user.Overtime = (reportedOvertime + aggregatedOvertime).Round(time.Minute) } else { log.Println("Cannot calculate overtime: ", err) diff --git a/Backend/helper/system.go b/Backend/helper/system.go index da3f736..2dfe9a8 100644 --- a/Backend/helper/system.go +++ b/Backend/helper/system.go @@ -20,6 +20,10 @@ func GetEnv(key, fallback string) string { return fallback } +func IsDebug() bool { + return GetEnv("GO_ENV", "production") == "debug" +} + type CacheItem struct { value any expiration time.Time diff --git a/Backend/helper/time.go b/Backend/helper/time.go index 7434a0e..3681ec6 100644 --- a/Backend/helper/time.go +++ b/Backend/helper/time.go @@ -4,6 +4,7 @@ package helper import ( "fmt" + "slices" "time" ) @@ -18,6 +19,64 @@ func GetMonday(ts time.Time) time.Time { return ts } +func GetMondays(allDays []time.Time, onlyInRange bool) []time.Time { + var mondays []time.Time + var start, end time.Time + + for _, day := range allDays { + mondays = append(mondays, GetMonday(day)) + + if start.IsZero() || day.Before(start) { + start = day + } + if end.IsZero() || day.After(end) { + end = day + } + } + mondays = slices.Compact(mondays) + if onlyInRange { + return DaysInRange(mondays, start, end) + } + return mondays +} + +func DaysInRange(days []time.Time, startDate, endDate time.Time) []time.Time { + filtered := []time.Time{} + startDate = startDate.Add(-time.Minute) + endDate = endDate.Add(time.Minute) + + for _, day := range days { + if day.After(startDate) && day.Before(endDate) { + filtered = append(filtered, day) + } + } + return filtered +} + +func IsMonday(day time.Time) bool { + return day.Weekday() == time.Monday +} + +// GenerateDateRange returns a slice of all dates between start and end (inclusive). +func GenerateDateRange(start, end time.Time) []time.Time { + var dates []time.Time + + // Ensure start is before or equal to end + if start.After(end) { + return dates + } + + // Normalize times to midnight + current := start.Truncate(time.Hour * 24) + end = end.Truncate(time.Hour * 24) + + for !current.After(end) { + dates = append(dates, current) + current = current.AddDate(0, 0, 1) // Add one day + } + return dates +} + func GetFirstOfMonth(ts time.Time) time.Time { if ts.Day() > 1 { return ts.AddDate(0, 0, -(ts.Day() - 1)) diff --git a/Backend/helper/time_test.go b/Backend/helper/time_test.go index 7905898..256d3af 100644 --- a/Backend/helper/time_test.go +++ b/Backend/helper/time_test.go @@ -26,6 +26,101 @@ func TestGetMonday(t *testing.T) { } } +func TestIsMonday_ReturnsTrueForMonday(t *testing.T) { + monday := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC) + + if !IsMonday(monday) { + t.Errorf("Expected IsMonday to return true for Monday, got false") + } +} + +func TestIsMonday_ReturnsFalseForNonMonday(t *testing.T) { + tuesday := time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC) + + if IsMonday(tuesday) { + t.Errorf("Expected IsMonday to return false for Tuesday, got true") + } +} + +func TestGenerateDateRange(t *testing.T) { + start := time.Date(2026, 2, 9, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC) + + dates := GenerateDateRange(start, end) + + if len(dates) != 3 { + t.Fatalf("expected 3 dates, got %d", len(dates)) + } + + expected := []string{"2026-02-09", "2026-02-10", "2026-02-11"} + for i, d := range dates { + got := d.Format("2006-01-02") + if got != expected[i] { + t.Errorf("expected %s, got %s", expected[i], got) + } + } +} + +func TestGetMondays_ReturnsOnlyMondays(t *testing.T) { + startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC) + endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC) + + daysInMonth := GenerateDateRange(startDate, endDate) + result := GetMondays(daysInMonth, false) + if len(result) < 5 { + t.Errorf("Expected 5 monday, got %d", len(result)) + } else if len(result) > 5 { + t.Errorf("Expected 5 monday, got %d", len(result)) + } + + if result[0] != time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC) { + t.Errorf("Expected first monday to be %v, got %v", "2025-12-29", result[0]) + } +} + +func TestGetMondays_ReturnsOnlyMondaysInRange(t *testing.T) { + startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC) + endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC) + + daysInMonth := GenerateDateRange(startDate, endDate) + result := GetMondays(daysInMonth, true) + if len(result) < 4 { + t.Errorf("Expected 4 monday, got %d", len(result)) + } else if len(result) > 4 { + t.Errorf("Expected 4 monday, got %d", len(result)) + } + + if result[0] != time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) { + t.Errorf("Expected first monday to be %v, got %v", "2026-01-05", result[0]) + } +} + +func TestDaysInRange(t *testing.T) { + days := []time.Time{ + time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC), // Tuesday + time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC), // Wednesday + time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC), // Thursday + time.Date(2023, 4, 6, 0, 0, 0, 0, time.UTC), // Friday + } + + start := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC) + + daysInRange := DaysInRange(days, start, end) + + if len(daysInRange) != 3 { + t.Errorf("Expected 3 days in range, got %d", len(daysInRange)) + } + + if daysInRange[0] != days[0] { + t.Errorf("Expected first day in range to be %v, got %v", days[0], daysInRange[0]) + } + + if daysInRange[2] != days[2] { + t.Errorf("Expected third day in range to be %v, got %v", days[2], daysInRange[2]) + } +} + func TestFormatDurationFill(t *testing.T) { testCases := []struct { name string diff --git a/Backend/helper/web.go b/Backend/helper/web.go index 3fcaadd..cdcc481 100644 --- a/Backend/helper/web.go +++ b/Backend/helper/web.go @@ -24,7 +24,7 @@ func SetCors(w http.ResponseWriter) { func RequiresLogin(session *scs.SessionManager, w http.ResponseWriter, r *http.Request) { r = r.WithContext(context.WithValue(r.Context(), "session", session)) - if GetEnv("GO_ENV", "production") == "debug" { + if IsDebug() { return } if session.Exists(r.Context(), "user") { diff --git a/Backend/main.go b/Backend/main.go index b909882..761cf17 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -38,7 +38,7 @@ func main() { if err != nil { slog.Info("No .env file found in directory!") } - if helper.GetEnv("GO_ENV", "production") == "debug" { + if helper.IsDebug() { logLevel.Set(slog.LevelDebug) envs := os.Environ() slog.Debug("Debug mode enabled", "Environment Variables", envs) diff --git a/Backend/models/absence.go b/Backend/models/absence.go index 21150f6..2246be3 100644 --- a/Backend/models/absence.go +++ b/Backend/models/absence.go @@ -63,7 +63,7 @@ func (a *Absence) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) return u.ArbeitszeitProTagFrac(float32(a.AbwesenheitTyp.WorkTime) / 100) case WorktimeBaseWeek: if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit { - return u.ArbeitszeitProTagFrac(0.2) + return u.ArbeitszeitProWocheFrac(0.2) } else if a.AbwesenheitTyp.WorkTime <= 0 { return 0 } diff --git a/Backend/models/booking.go b/Backend/models/booking.go index d406de4..8072a0f 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -188,7 +188,7 @@ func (b Booking) Save() { } func (b *Booking) GetBookingType() string { - debug := (helper.GetEnv("GO_ENV", "production") == "debug") + debug := helper.IsDebug() switch b.CheckInOut { case 1: //manuelle Änderung return "kommen" diff --git a/Backend/models/iworkday.go b/Backend/models/iworkday.go index 3f96166..53fa920 100644 --- a/Backend/models/iworkday.go +++ b/Backend/models/iworkday.go @@ -55,7 +55,9 @@ func GetDays(user User, tsFrom, tsTo time.Time, orderedForward bool) []IWorkDay } for _, absentDay := range absences { - + if weekDay := absentDay.Date().Weekday(); weekDay == time.Saturday || weekDay == time.Sunday { + continue + } // Check if there is already a day existingDay, ok := allDays[absentDay.Date().Format(time.DateOnly)] switch { diff --git a/Backend/models/publicHoliday.go b/Backend/models/publicHoliday.go index bc1b11d..d4fb0d1 100644 --- a/Backend/models/publicHoliday.go +++ b/Backend/models/publicHoliday.go @@ -119,6 +119,8 @@ func (p *PublicHoliday) RequiresAction() bool { } func (p *PublicHoliday) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + return 0 + switch base { case WorktimeBaseDay: return u.ArbeitszeitProTagFrac(float32(p.worktime) / 100) diff --git a/Backend/models/user.go b/Backend/models/user.go index 5389c33..cf74fd9 100644 --- a/Backend/models/user.go +++ b/Backend/models/user.go @@ -33,7 +33,7 @@ type User struct { func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, error) { var user User var err error - if helper.GetEnv("GO_ENV", "production") == "debug" { + if helper.IsDebug() { user, err = GetUserByPersonalNr(123) } else { if !Session.Exists(ctx, "user") { @@ -50,15 +50,15 @@ func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, } // Returns the actual overtime for this moment -func (u *User) GetReportedOvertime() (time.Duration, error) { +func (u *User) GetReportedOvertime(startDate time.Time) (time.Duration, error) { var overtime time.Duration - qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1;") + qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1 AND woche_start::DATE <= $2::DATE;") if err != nil { return 0, err } defer qStr.Close() - err = qStr.QueryRow(u.PersonalNummer).Scan(&overtime) + err = qStr.QueryRow(u.PersonalNummer, startDate).Scan(&overtime) if err != nil { return 0, err } diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 3e9eb05..299bf8f 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -151,12 +151,21 @@ func (d *WorkDay) Type() DayType { return DayTypeWorkday } -func (d *WorkDay) GenerateKurzArbeitBookings(u User) (time.Time, time.Time) { +func (d *WorkDay) GenerateKurzArbeitBookings(u User, weekBase WorktimeBase) (time.Time, time.Time) { var timeFrom, timeTo time.Time if d.GetWorktime(u, WorktimeBaseDay, false) >= u.ArbeitszeitProTag() { return timeFrom, timeTo } + if d.IsEmpty() { + switch weekBase { + case WorktimeBaseDay: + return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProTag()) + case WorktimeBaseWeek: + return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProWocheFrac(0.2)) + } + } + timeFrom = d.Bookings[len(d.Bookings)-1].Timestamp.Add(time.Minute) timeTo = timeFrom.Add(u.ArbeitszeitProTag() - d.GetWorktime(u, WorktimeBaseDay, false)) slog.Debug("Added duration as Kurzarbeit", "date", d.Date().String(), "duration", timeTo.Sub(timeFrom).String()) @@ -169,7 +178,7 @@ func (d *WorkDay) GetKurzArbeit() *Absence { } func (d *WorkDay) ToString() string { - return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime)) + return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s. Is KurzArbeit %v", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime), d.IsKurzArbeit()) } func (d *WorkDay) IsWorkDay() bool { diff --git a/Backend/models/workWeek.go b/Backend/models/workWeek.go index 1dfcbd3..ba32fba 100644 --- a/Backend/models/workWeek.go +++ b/Backend/models/workWeek.go @@ -6,6 +6,7 @@ package models // this type is based on the "wochen_report" table import ( + "arbeitszeitmessung/helper" "database/sql" "errors" "log" @@ -24,10 +25,12 @@ type WorkWeek struct { Days []IWorkDay User User WeekStart time.Time + weekEnd time.Time Worktime time.Duration WorktimeVirtual time.Duration Overtime time.Duration Status WeekStatus + WeekBase WorktimeBase } type WeekStatus int8 @@ -40,10 +43,15 @@ const ( WeekStatusDifferences ) -func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek { +func NewWorkWeekSimple(user User, tsMonday time.Time, populate bool) WorkWeek { + return NewWorkWeek(user, tsMonday, tsMonday.Add(6*24*time.Hour), populate) +} + +func NewWorkWeek(user User, tsStart, tsEnd time.Time, populate bool) WorkWeek { var week WorkWeek = WorkWeek{ User: user, - WeekStart: tsMonday, + WeekStart: tsStart, + weekEnd: tsEnd, Status: WeekStatusNone, } if populate { @@ -53,13 +61,20 @@ func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek { } func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Duration) { - slog.Debug("Populating Workweek for user", "user", w.User) slog.Debug("Got Days with overtime and worktime", slog.String("worktime", worktime.String()), slog.String("overtime", overtime.String())) - w.Days = GetDays(w.User, w.WeekStart, w.WeekStart.Add(6*24*time.Hour), false) + w.Days = GetDays(w.User, w.WeekStart, w.weekEnd, false) + slog.Debug("Populating Workweek for user", "user", w.User.Name, "Days", lenWorkDays(w.Days), "Start", w.WeekStart, "End", w.weekEnd, "workdays", helper.GetWorkingDays(w.WeekStart, w.weekEnd)) + + if lenWorkDays(w.Days) == helper.GetWorkingDays(w.WeekStart, w.weekEnd) { + w.WeekBase = WorktimeBaseWeek + } else { + w.WeekBase = WorktimeBaseDay + } for _, day := range w.Days { - w.Worktime += day.GetWorktime(w.User, WorktimeBaseDay, false) - w.WorktimeVirtual += day.GetWorktime(w.User, WorktimeBaseDay, true) + w.Worktime += day.GetWorktime(w.User, w.WeekBase, false) + w.WorktimeVirtual += day.GetWorktime(w.User, w.WeekBase, true) + slog.Debug("Calculated Worktime", "Day", day.ToString(), "worktime", w.Worktime.String()) } slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String()) @@ -79,6 +94,16 @@ func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Durati } } +func lenWorkDays(workDays []IWorkDay) int { + var lenght int + for _, day := range workDays { + if !day.IsEmpty() || day.IsKurzArbeit() { + lenght += 1 + } + } + return lenght +} + func (w *WorkWeek) CheckStatus() WeekStatus { if w.Status != WeekStatusNone { return w.Status diff --git a/Backend/models/workWeek_test.go b/Backend/models/workWeek_test.go index ef989e4..a1360a6 100644 --- a/Backend/models/workWeek_test.go +++ b/Backend/models/workWeek_test.go @@ -20,7 +20,7 @@ func TestNewWorkWeekNoPopulate(t *testing.T) { if err != nil { t.Fatal(err) } - workWeek := models.NewWorkWeek(testUser, monday, false) + workWeek := models.NewWorkWeekSimple(testUser, monday, false) if workWeek.User != testUser || workWeek.WeekStart != monday { t.Error("No populate workweek does not have right values!") diff --git a/Backend/static/css/styles.css b/Backend/static/css/styles.css index b26c407..004d9fa 100644 --- a/Backend/static/css/styles.css +++ b/Backend/static/css/styles.css @@ -202,15 +202,9 @@ .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1/2 * 100%); } - .top-2 { - top: calc(var(--spacing) * 2); - } .top-2\.5 { top: calc(var(--spacing) * 2.5); } @@ -220,15 +214,9 @@ .right-1 { right: calc(var(--spacing) * 1); } - .right-2 { - right: calc(var(--spacing) * 2); - } .right-2\.5 { right: calc(var(--spacing) * 2.5); } - .left-1 { - left: calc(var(--spacing) * 1); - } .left-1\/2 { left: calc(1/2 * 100%); } @@ -253,9 +241,6 @@ .my-2 { margin-block: calc(var(--spacing) * 2); } - .my-4 { - margin-block: calc(var(--spacing) * 4); - } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -428,20 +413,9 @@ width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); } - .size-8 { - width: calc(var(--spacing) * 8); - height: calc(var(--spacing) * 8); - } - .size-10 { - width: calc(var(--spacing) * 10); - height: calc(var(--spacing) * 10); - } .h-2 { height: calc(var(--spacing) * 2); } - .h-3 { - height: calc(var(--spacing) * 3); - } .h-3\.5 { height: calc(var(--spacing) * 3.5); } @@ -466,9 +440,6 @@ .w-2 { width: calc(var(--spacing) * 2); } - .w-3 { - width: calc(var(--spacing) * 3); - } .w-3\.5 { width: calc(var(--spacing) * 3.5); } @@ -478,9 +449,6 @@ .w-5 { width: calc(var(--spacing) * 5); } - .w-9 { - width: calc(var(--spacing) * 9); - } .w-9\/10 { width: calc(9/10 * 100%); } @@ -493,9 +461,6 @@ .w-full { width: 100%; } - .flex-shrink { - flex-shrink: 1; - } .flex-shrink-0 { flex-shrink: 0; } @@ -511,21 +476,10 @@ .basis-\[content\] { flex-basis: content; } - .border-collapse { - border-collapse: collapse; - } - .-translate-x-1 { - --tw-translate-x: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-x-1\/2 { --tw-translate-x: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -536,9 +490,6 @@ .cursor-pointer { cursor: pointer; } - .resize { - resize: both; - } .scroll-m-2 { scroll-margin: calc(var(--spacing) * 2); } @@ -665,18 +616,12 @@ .bg-red-600 { background-color: var(--color-red-600); } - .mask-repeat { - mask-repeat: repeat; - } .p-1 { padding: calc(var(--spacing) * 1); } .p-2 { padding: calc(var(--spacing) * 2); } - .px-2 { - padding-inline: calc(var(--spacing) * 2); - } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -737,16 +682,9 @@ .uppercase { text-transform: uppercase; } - .underline { - text-decoration-line: underline; - } .opacity-0 { opacity: 0%; } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -1180,11 +1118,6 @@ syntax: "*"; inherits: false; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -1257,7 +1190,6 @@ --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-font-weight: initial; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/Backend/templates/reportPage.templ b/Backend/templates/reportPage.templ index 8942fc0..7e2ceb9 100644 --- a/Backend/templates/reportPage.templ +++ b/Backend/templates/reportPage.templ @@ -66,7 +66,7 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
@timeGaugeComponent(int8(progress), false)
-

Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDuration(week.Worktime)) }

+

Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Worktime, true)) }

Überstunden: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Overtime, true)) }

From 1d7b563a6db78aa10e04f0d30576dea462f725dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 16:07:34 +0100 Subject: [PATCH 05/14] fix: install script added dynamic backup folder (fixed #79) changed if statements --- Cron/autoBackup.sh | 3 ++- Docker/env.example | 3 ++- install.sh | 43 +++++++++++++++++++++++++------------------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Cron/autoBackup.sh b/Cron/autoBackup.sh index 5430a1d..1be8589 100755 --- a/Cron/autoBackup.sh +++ b/Cron/autoBackup.sh @@ -1,6 +1,7 @@ # cron-timing: 05 01 * * 1 container_name="arbeitszeitmessung-main-db-1" filename=backup-$(date '+%d%m%Y').sql +backup_folder=__BACKUP_FOLDER__ database_name=__DATABASE__ -docker exec $container_name pg_dump $database_name > /home/pi/arbeitszeitmessung-backup/$filename +docker exec $container_name pg_dump $database_name > $backup_folder/$filename echo "created backup file: "$filename diff --git a/Docker/env.example b/Docker/env.example index 1a150dd..1a23768 100644 --- a/Docker/env.example +++ b/Docker/env.example @@ -4,9 +4,10 @@ POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (f POSTGRES_API_PASS=password # Postgres API Passwort (für Arbeitszeitmessung) POSTGRES_PATH=__ROOT__/DB # Datebank Pfad (relativ zu Docker Ordner oder absoluter pfad mit /...) POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name -POSTGRES_PORT=127.0.0.1:5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$ +POSTGRES_PORT=5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$ TZ=Europe/Berlin # Zeitzone API_TOKEN=dont_access # API Token für ESP Endpoints WEB_PORT=8000 # Port unter welchem Webserver erreichbar ist. regex:^[0-9]{1,5}$ LOG_PATH=__ROOT__/logs # Pfad für Audit Logs LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen +BACKUP_FOLDER=__ROOT__/backup # Pfad für DB Backup Datein diff --git a/install.sh b/install.sh index 51c1ed7..eb7ae33 100755 --- a/install.sh +++ b/install.sh @@ -64,9 +64,9 @@ if [ ! -f $envFile ]; then while true; do if [ -z "$comment" ]; then - printf "Value for $key - $comment (default: $default_value" - else printf "Value for $key (default: $default_value" + else + printf "Value for $key - $comment (default: $default_value" fi if [ -n "$regex" ]; then printf ", must match: %s" "$regex" @@ -113,27 +113,12 @@ LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2) if [ -z "$LOG_PATH" ]; then echo "LOG_PATH not found in .env using default $(pwd)/logs" LOG_PATH=$(pwd)/logs -else - LOG_PATH=Docker/$LOG_PATH fi mkdir -p $LOG_PATH echo "Created logs folder at $LOG_PATH" ########################################################################### -echo -e "\n\n" -echo "Start containers with docker compose up -d? [y/N]" -read -r start_containers -if [[ "$start_containers" =~ ^[Yy]$ ]]; then - cd Docker - docker compose up -d - echo "Containers started." -else - echo "You can start them manually with: docker compose up -d" -fi - -########################################################################### - echo -e "\n\n" echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]" read -r setup_cron @@ -146,13 +131,20 @@ if [[ "$setup_cron" =~ ^[Yy]$ ]]; then POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2) if [ -z "$POSTGRES_DB" ]; then - echo "arbeitszeitmessung not found in .env using default arbeitszeitmessung" + echo "POSTGRES_DB not found in .env using default arbeitszeitmessung" POSTGRES_DB="arbeitszeitmessung" fi + BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2) + if [ -z "$BACKUP_FOLDER" ]; then + echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup" + BACKUP_FOLDER="$(pwd)/backup" + fi + sed -i "s/__PORT__/$WEB_PORT/" $autoHolidaysScript sed -i "s/__PORT__/$WEB_PORT/" $autoLogoutScript sed -i "s/__DATABASE__/$POSTGRES_DB/" $autoBackupScript + sed -i "s/__BACKUP_FOLDER__/$BACKUP_FOLDER" $autoBackupScript chmod +x $autoBackupScript $autoHolidaysScript $autoLogoutScript @@ -184,3 +176,18 @@ if [[ "$setup_cron" =~ ^[Yy]$ ]]; then else echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!" fi + +########################################################################### + +echo -e "\n\n" +echo "Start containers with docker compose up -d? [y/N]" +read -r start_containers +if [[ "$start_containers" =~ ^[Yy]$ ]]; then + cd Docker + docker compose up -d + echo "Containers started." +else + echo "You can start them manually with: docker compose up -d" +fi + +echo "Installation finished, you can re-run the script any time!" From 61ce5aab3ad419c92e9bc2feb0ac1c7c23067282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 16:10:14 +0100 Subject: [PATCH 06/14] fix: log verbosity auto/logout to not expose names --- Backend/endpoints/auto-logout.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/endpoints/auto-logout.go b/Backend/endpoints/auto-logout.go index 5b45672..5ec5f85 100644 --- a/Backend/endpoints/auto-logout.go +++ b/Backend/endpoints/auto-logout.go @@ -35,7 +35,7 @@ func autoLogout(w http.ResponseWriter) { fmt.Printf("Error logging out user %v\n", err) } else { loggedOutUsers = append(loggedOutUsers, user) - log.Printf("Automaticaly logged out user %s, %s ", user.Name, user.Vorname) + log.Printf("Automaticaly logged out user %d ", user.PersonalNummer) } } From 7e54800bc3cf21eda85a50e3ab5884674f7802c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 16:23:54 +0100 Subject: [PATCH 07/14] chore(docs): redid readme + cleanup --- Backend/template.typ | 92 --------------------------- Readme.md | 147 +++++++++++-------------------------------- 2 files changed, 38 insertions(+), 201 deletions(-) delete mode 100644 Backend/template.typ diff --git a/Backend/template.typ b/Backend/template.typ deleted file mode 100644 index ade04ab..0000000 --- a/Backend/template.typ +++ /dev/null @@ -1,92 +0,0 @@ -#let table-header(..headers) = { - table.header( - ..headers.pos().map(h => strong(h)) - ) -} - - -#let abrechnung(meta, days) = { - set page(paper: "a4", margin: (x:1.5cm, y:2.25cm), - footer:[#grid( - columns: (3fr, .65fr), - align: left + horizon, - inset: .5em, - [#meta.EmployeeName -- #meta.TimeRange], grid.cell(rowspan: 2)[#image("static/logo.png")], - [Arbeitszeitrechnung maschinell erstellt am #meta.CurrentTimestamp], - ) - ]) - set text(font: "Noto Sans", size:10pt, fill: luma(10%)) - set table( - stroke: 0.5pt + luma(10%), - inset: .5em, - align: center + horizon, - ) - show text: it => { - if it.text == "0min"{ - text(oklch(70.8%, 0, 0deg))[#it] - }else if it.text.starts-with("-"){ - text(red)[#it] - }else{ - it - } - } - - - [= Abrechnung Arbeitszeit -- #meta.EmployeeName] - - [Zeitraum: #meta.TimeRange] - - table( - columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1.25fr), - fill: (x, y) => - if y == 0 { oklch(87%, 0, 0deg) }, - table-header( - [Datum], [Kommen], [Gehen], [Arbeitsart], [Stunden], [Pause], [Überstunden] - ), - .. for day in days { - ( - [#day.Date], - if day.DayParts.len() == 0{ - table.cell(colspan: 3)[Keine Buchungen] - }else if not day.DayParts.first().IsWorkDay{ - table.cell(colspan: 3)[#day.DayParts.first().WorkType] - } - else { - - table.cell(colspan: 3, inset: 0em)[ - - #table( - columns: (1fr, 1fr, 1fr), - .. for Zeit in day.DayParts { - ( - [#Zeit.BookingFrom], - [#Zeit.BookingTo], - [#Zeit.WorkType], - ) - }, - ) - ] - }, - [#day.Worktime], - [#day.Pausetime], - [#day.Overtime], - ) - if day.IsFriday { - ( table.cell(colspan: 7, fill: oklch(87%, 0, 0deg))[Wochenende], ) // note the trailing comma - } - } - ) - - table( - columns: (3fr, 1fr), - align: right, - inset: (x: .25em, y:.75em), - stroke: none, - table.hline(start: 0, end: 2, stroke: stroke(dash:"dashed", thickness:.5pt)), - [Arbeitszeit :], table.cell(align: left)[#meta.WorkTime], - [Überstunden :], table.cell(align: left)[#meta.Overtime], - [Überstunden :],table.cell(align: left)[#meta.OvertimeTotal], - table.hline(start: 0, end: 2), - -) -} diff --git a/Readme.md b/Readme.md index c058ab8..eb15764 100644 --- a/Readme.md +++ b/Readme.md @@ -2,124 +2,53 @@ [![Quality Gate Status](https://sonar.letsstein.de/api/project_badges/measure?project=arbeitszeitmessung&metric=alert_status&token=sqb_253028eff30aff24f32b437cd6c484c511b5c33f)](https://sonar.letsstein.de/dashboard?id=arbeitszeitmessung) -bis jetzt ein einfaches Backend mit PostgreSQL Datenbank und GO Webserver um Arbeitszeitbuchungen per HTTP PUT einzufügen +--- + +Eine open-source Software zur Arbeitszeitmessung + +## Features + +- manuelle Korrektur von einzelnen Buchungen +- Buchung von benutzerdefinierten Abwesenheiten +- automatische gesetzlicher Feiertage +- Pflege eigener Feiertage + +- wöchentliches Abrechnungssystem +- Kontrolle der Arbeitszeiten durch direkte Führungskraft + +- Ausgabe der Arbeitszeiten je Monat in PDF Format + +- Anwesenheitsübersicht ## Installation ```bash git clone https://git.letsstein.de/tom/arbeitszeitmessung arbeitszeitmessung -cd arbeitszeitmessung/Docker -# .env Datei anpassen -docker compose up -d +cd arbeitszeitmessung + +./install.sh ``` -## PREVIEW +### Konfiguration: -Zeitverwaltungsansicht (/time): - -![time](docs/images/time.png) - -Ansicht der Führungskraft (/team): - -![team](docs/images/team.png) - -Nutzeransicht (/user): - -![user](docs/images/user.png) - -## Buchungstypen - -1 - Kommen -2 - Gehen -3 - Kommen Manuell -4 - Gehen Manuell -254 - Automatisch abgemeldet - -## API - -Nutzung der API -wenn die `dev-docker-compose.yml` Datei gestartet wird, ist direkt ein SwaggerUI Server mit entsprechender Datei inbegriffen. - -### Buchungen [/time] - -#### [GET] Anfrage - -Parameter: cardID (string) -Antwort: `200` - -```json -[ - { - "cradID": "test_card", - "readerID": "test_reader", - "bookingTyp": 2, - "loggedTime": "2024-09-05T08:37:53.117641Z", - "id": 5 - }, - { - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z", - "id": 6 - } -] -``` - -Antwort `500` -Serverfehler - -#### [PUT] Anfrage - -Parameter: id (int) -Body: (veränderte Parameter) - -```json -{ - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z" -} -``` - -Antwort `200` - -```json -{ - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z", - "id": 6 -} -``` - -### Neue Buchung [/time/new] - -#### [PUT] Anfrage - -Parameter: - -- cardID (string) -- readerID (string) -- bookingType (string) - -Antwort `202` Akzeptiert und eingefügt - -```json -{ - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z", - "id": 6 -} -``` - -Antwort `409` Konflikt -Die vorherige Buchung am selben Tag hat den gleichen Buchungstyp +- Datenbank + - `POSTGRES_USER` Postgres ADMIN Nutzername + - `POSTGRES_PASSWORD` Postgres ADMIN Passwort + - `POSTGRES_API_USER` Postgres API Nutzername für Webanwendung + - `POSTGRES_API_PASS` Postgres API Passwort für Webanwendung + - `POSTGRES_PATH` Datebank Pfad + - `POSTGRES_DB` Postgres Datenbank Name + - `POSTGRES_PORT` Postgres Port für administration +- System + - `TZ` Zeitzone + - `LOG_LEVEL` Welche Log-Nachrichten werden in der Konsole erscheinen +- Web/API + - `API_TOKEN` API Token für ESP Endpoints + - `WEB_PORT` Port unter welchem Webserver erreichbar ist +- Ordnerstruktur + - `BACKUP_FOLDER` Pfad für DB Backup Datein + - `LOG_PATH` Pfad für Audit Logs # Filestrukture From 23896e4f08367898506e24f3a333b3a8cb685241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 18:16:12 +0100 Subject: [PATCH 08/14] fix: wrong kurzarbeit calculation fixed #80 --- Backend/endpoints/pdf-create.go | 57 +++++++++------------------------ Backend/models/publicHoliday.go | 2 -- Backend/models/workWeek.go | 10 ++++-- 3 files changed, 24 insertions(+), 45 deletions(-) diff --git a/Backend/endpoints/pdf-create.go b/Backend/endpoints/pdf-create.go index 01efb09..6682409 100644 --- a/Backend/endpoints/pdf-create.go +++ b/Backend/endpoints/pdf-create.go @@ -21,7 +21,7 @@ import ( const DE_DATE string = "02.01.2006" const FILE_YEAR_MONTH string = "2006_01" -const PDF_DIRECTORY = "/home/tom/Code/arbeitszeitmessung/Backend/doc" +var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/doc/") // TODO func PDFCreateController(w http.ResponseWriter, r *http.Request) { helper.RequiresLogin(Session, w, r) @@ -99,9 +99,11 @@ func convertDaysToTypst(days []models.IWorkDay, u models.User, weekbase models.W var thisTypstDay typstDay workVirtual, pause, overtime := day.GetTimes(u, weekbase, true) + overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2) - if day.Type() != models.DayTypeHoliday { - overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2) + if day.Type() == models.DayTypeHoliday { + // workVirtual = 0 + overtime = 0 } thisTypstDay.Date = day.Date().Format(DE_DATE) thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true) @@ -173,13 +175,13 @@ func createReports(employes []models.User, startDate time.Time) []typstData { } func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) { - publicHolidays, _ := models.GetHolidaysFromTo(startDate, endDate) - targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)-len(publicHolidays)) - daysThisMonth := helper.GenerateDateRange(startDate, endDate) - mondaysThisMonth := helper.GetMondays(daysThisMonth, false) + // publicHolidays, _ := models.GetHolidaysFromTo(startDate, endDate) + targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)) //-len(publicHolidays) + mondaysThisMonth := helper.GetMondays(helper.GenerateDateRange(startDate, endDate), false) var weeks []models.WorkWeek var workHours, kurzarbeitHours time.Duration + for _, monday := range mondaysThisMonth { var week models.WorkWeek if monday.After(startDate) { @@ -188,9 +190,13 @@ func createEmployeReport(employee models.User, startDate, endDate time.Time) (ty week = models.NewWorkWeek(employee, startDate, monday.Add(6*24*time.Hour), true) } workHours += week.WorktimeVirtual - kurzarbeitHours += week.WorktimeVirtual - week.Worktime + kurzarbeitHours += week.Kurzarbeit weeks = append(weeks, week) } + + monthOvertime := workHours - targetHoursThisMonth + totalOvertime, err := employee.GetReportedOvertime(endDate) + var typstDays []typstDay for _, week := range weeks { weekTypstDays, err := convertDaysToTypst(week.Days, employee, week.WeekBase) @@ -201,37 +207,6 @@ func createEmployeReport(employee models.User, startDate, endDate time.Time) (ty typstDays = append(typstDays, weekTypstDays...) } - slog.Info("Weeks for the month", "week len", len(weeks), "week", weeks) - // workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false) - - // var weekbase models.WorktimeBase - // if lenWorkDays(workDaysThisMonth) == helper.GetWorkingDays(startDate, endDate) { - // weekbase = models.WorktimeBaseWeek - // } else { - // weekbase = models.WorktimeBaseDay - // } - - // slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours(), "days", helper.GetWorkingDays(startDate, endDate), "workdays", lenWorkDays(workDaysThisMonth)) - - // var workHours, kurzarbeitHours time.Duration - // for _, day := range workDaysThisMonth { - // tmpvirtualHours := day.GetWorktime(employee, weekbase, true) - // tmpactualHours := day.GetWorktime(employee, weekbase, false) - // if day.IsKurzArbeit() && tmpvirtualHours > tmpactualHours { - // slog.Debug("Adding kurzarbeit to workday", "day", day.Date()) - // kurzarbeitHours += tmpvirtualHours - tmpactualHours - // } - // workHours += tmpvirtualHours - // } - worktimeBalance := workHours - targetHoursThisMonth - - // typstDays, err := convertDaysToTypst(workDaysThisMonth, employee, weekbase) - // if err != nil { - // slog.Warn("Failed to convert to days", slog.Any("error", err)) - // return typstData{}, err - // } - - totalOvertime, err := employee.GetReportedOvertime(endDate) if err != nil { slog.Error("Cannot retrieve total Overtime", "Error", err) } @@ -239,10 +214,10 @@ func createEmployeReport(employee models.User, startDate, endDate time.Time) (ty metadata := typstMetadata{ EmployeeName: fmt.Sprintf("%s %s", employee.Vorname, employee.Name), TimeRange: fmt.Sprintf("%s - %s", startDate.Format(DE_DATE), endDate.Format(DE_DATE)), - Overtime: helper.FormatDurationFill(worktimeBalance, true), + Overtime: helper.FormatDurationFill(monthOvertime, true), WorkTime: helper.FormatDurationFill(workHours, true), Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true), - OvertimeTotal: helper.FormatDurationFill(totalOvertime+worktimeBalance, true), + OvertimeTotal: helper.FormatDurationFill(totalOvertime+monthOvertime, true), CurrentTimestamp: time.Now().Format("02.01.2006 - 15:04 Uhr"), } return typstData{Meta: metadata, Days: typstDays, FileName: fmt.Sprintf("%s_%s.pdf", startDate.Format(FILE_YEAR_MONTH), employee.Name)}, nil diff --git a/Backend/models/publicHoliday.go b/Backend/models/publicHoliday.go index d4fb0d1..bc1b11d 100644 --- a/Backend/models/publicHoliday.go +++ b/Backend/models/publicHoliday.go @@ -119,8 +119,6 @@ func (p *PublicHoliday) RequiresAction() bool { } func (p *PublicHoliday) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { - return 0 - switch base { case WorktimeBaseDay: return u.ArbeitszeitProTagFrac(float32(p.worktime) / 100) diff --git a/Backend/models/workWeek.go b/Backend/models/workWeek.go index ba32fba..5a4ab18 100644 --- a/Backend/models/workWeek.go +++ b/Backend/models/workWeek.go @@ -31,6 +31,7 @@ type WorkWeek struct { Overtime time.Duration Status WeekStatus WeekBase WorktimeBase + Kurzarbeit time.Duration } type WeekStatus int8 @@ -72,8 +73,13 @@ func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Durati } for _, day := range w.Days { - w.Worktime += day.GetWorktime(w.User, w.WeekBase, false) - w.WorktimeVirtual += day.GetWorktime(w.User, w.WeekBase, true) + dWorkTime := day.GetWorktime(w.User, w.WeekBase, false) + dWorkTimeVirtual := day.GetWorktime(w.User, w.WeekBase, true) + if dWorkTime < dWorkTimeVirtual { + w.Kurzarbeit += dWorkTimeVirtual - dWorkTime + } + w.Worktime += dWorkTime + w.WorktimeVirtual += dWorkTimeVirtual slog.Debug("Calculated Worktime", "Day", day.ToString(), "worktime", w.Worktime.String()) } slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String()) From 10df10a606b0ac2c5a65a149ba288fdcabcb7b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 18:30:28 +0100 Subject: [PATCH 09/14] fix: calc worktime, when nothing is set --- Backend/models/booking_test.go | 12 ++++++------ Backend/models/workDay.go | 2 +- Backend/models/workDay_test.go | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Backend/models/booking_test.go b/Backend/models/booking_test.go index c5f3e67..d344e40 100644 --- a/Backend/models/booking_test.go +++ b/Backend/models/booking_test.go @@ -13,35 +13,35 @@ var testBookingType = models.BookingType{ var testBookings8hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), BookingType: testBookingType, }, { CardUID: "aaaa-aaaa", CheckInOut: 2, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:00")), + Timestamp: time.Date(2025, 01, 01, 16, 0, 0, 0, time.UTC), BookingType: testBookingType, }} var testBookings6hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), BookingType: testBookingType, }, { CardUID: "aaaa-aaaa", CheckInOut: 2, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 14:00")), + Timestamp: time.Date(2025, 01, 01, 14, 0, 0, 0, time.UTC), BookingType: testBookingType, }} var testBookings10hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), BookingType: testBookingType, }, { CardUID: "aaaa-aaaa", CheckInOut: 2, - Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 18:00")), + Timestamp: time.Date(2025, 01, 01, 18, 0, 0, 0, time.UTC), BookingType: testBookingType, }} diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 299bf8f..974696e 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -85,7 +85,7 @@ func (d *WorkDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (w func getWorkPause(d *WorkDay) (work, pause time.Duration) { //if today calc, else take from db - if helper.IsSameDate(d.Date(), time.Now()) { + if d.workTime == 0 && d.pauseTime == 0 && len(d.Bookings) > 0 { return calcWorkPause(d.Bookings) } else { return d.workTime, d.pauseTime diff --git a/Backend/models/workDay_test.go b/Backend/models/workDay_test.go index 9ad0e6d..a3fe7be 100644 --- a/Backend/models/workDay_test.go +++ b/Backend/models/workDay_test.go @@ -16,10 +16,10 @@ func CatchError[T any](val T, err error) T { } var testWorkDay = models.WorkDay{ - Day: CatchError(time.Parse(time.DateOnly, "2025-01-01")), + Day: time.Date(2025, 01, 01, 0, 0, 0, 0, time.Local), Bookings: testBookings8hrs, - TimeFrom: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), - TimeTo: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:30")), + TimeFrom: time.Date(2025, 01, 01, 8, 0, 0, 0, time.Local), + TimeTo: time.Date(2025, 01, 01, 16, 30, 0, 0, time.Local), } func TestWorkdayWorktimeDay(t *testing.T) { From b4bf550863f8b1ee6da8623384875c943b659990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Sun, 15 Feb 2026 18:49:29 +0100 Subject: [PATCH 10/14] fix: closing some issues from sonarqube --- Backend/endpoints/pdf-create.go | 2 +- Backend/endpoints/time.go | 7 ------- Backend/helper/logs/main.go | 4 ++-- Backend/models/compoundDay.go | 4 +--- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Backend/endpoints/pdf-create.go b/Backend/endpoints/pdf-create.go index 6682409..b680a16 100644 --- a/Backend/endpoints/pdf-create.go +++ b/Backend/endpoints/pdf-create.go @@ -21,7 +21,7 @@ import ( const DE_DATE string = "02.01.2006" const FILE_YEAR_MONTH string = "2006_01" -var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/doc/") // TODO +var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/doc/") func PDFCreateController(w http.ResponseWriter, r *http.Request) { helper.RequiresLogin(Session, w, r) diff --git a/Backend/endpoints/time.go b/Backend/endpoints/time.go index 883c7b3..227fb5f 100644 --- a/Backend/endpoints/time.go +++ b/Backend/endpoints/time.go @@ -257,12 +257,6 @@ func updateAbsence(r *http.Request) error { log.Println("Cannot get Absence for id: ", absenceId, err) return err } - if r.FormValue("action") == "delete" { - log.Println("Deleting Absence!", "Not implemented") - // TODO - //absence.Delete() - return nil - } if absence.Update(newAbsence) { err = absence.Save() @@ -272,5 +266,4 @@ func updateAbsence(r *http.Request) error { } } return nil - } diff --git a/Backend/helper/logs/main.go b/Backend/helper/logs/main.go index 7744bf7..7afb6fd 100644 --- a/Backend/helper/logs/main.go +++ b/Backend/helper/logs/main.go @@ -17,8 +17,8 @@ type FileLog struct { var Logs map[string]FileLog = make(map[string]FileLog) func NewAudit() (i *log.Logger, close func() error) { - LOG_FILE := "logs/" + time.Now().Format(time.DateOnly) + ".log" - logFile, err := os.OpenFile(LOG_FILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logName := "logs/" + time.Now().Format(time.DateOnly) + ".log" + logFile, err := os.OpenFile(logName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Panic(err) } diff --git a/Backend/models/compoundDay.go b/Backend/models/compoundDay.go index c3aac27..dd89665 100644 --- a/Backend/models/compoundDay.go +++ b/Backend/models/compoundDay.go @@ -21,9 +21,7 @@ type CompoundDay struct { func (c *CompoundDay) IsSubmittedAndAccepted() bool { var isSubmittedAndAccepted = true for _, day := range c.DayParts { - _isSubmittedAndAccepted := day.IsSubmittedAndAccepted() - isSubmittedAndAccepted = isSubmittedAndAccepted && _isSubmittedAndAccepted - slog.Info("Result from IsSubmittedCheck", "Result", _isSubmittedAndAccepted, "compount", day.ToString()) + isSubmittedAndAccepted = isSubmittedAndAccepted && day.IsSubmittedAndAccepted() } return isSubmittedAndAccepted } From f21ce9a3c3598c6e8e0f463fdf203ffc5012b3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Tue, 24 Feb 2026 20:05:08 +0100 Subject: [PATCH 11/14] fixed: pause time calculation from work instead of presence time --- Backend/models/booking_test.go | 215 +++++++++++++++++++++++++++++++++ Backend/models/workDay.go | 3 +- Backend/models/workDay_test.go | 102 +++++++++++++++- Readme.md | 91 ++++++++++++++ 4 files changed, 404 insertions(+), 7 deletions(-) diff --git a/Backend/models/booking_test.go b/Backend/models/booking_test.go index d344e40..48f44f9 100644 --- a/Backend/models/booking_test.go +++ b/Backend/models/booking_test.go @@ -45,3 +45,218 @@ var testBookings10hrs = []models.Booking{{ Timestamp: time.Date(2025, 01, 01, 18, 0, 0, 0, time.UTC), BookingType: testBookingType, }} + +var testBookings6hrsBreak30min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 14, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }} + +var testBookings610hrsBreak30min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 14, 40, 0, 0, time.UTC), + BookingType: testBookingType, + }} + +var testBookings9hrsBreak30min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }} + +var testBookings930hrs = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }} + +var testBookings910hrsBreak30min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 17, 40, 0, 0, time.UTC), + BookingType: testBookingType, + }, +} + +var testBookings910hrsBreak35min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 35, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC), + BookingType: testBookingType, + }, +} + +var testBookings945hrs = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC), + BookingType: testBookingType, + }, +} + +var testBookings10hrsBreak45min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 18, 00, 0, 0, time.UTC), + BookingType: testBookingType, + }, +} + +var testBookings1030hrsBreak45min = []models.Booking{ + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 1, + Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC), + BookingType: testBookingType, + }, + { + CardUID: "aaaa-aaaa", + CheckInOut: 2, + Timestamp: time.Date(2025, 01, 01, 18, 30, 0, 0, time.UTC), + BookingType: testBookingType, + }, +} diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 974696e..7a6e6e3 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -116,7 +116,8 @@ func correctWorkPause(workIn, pauseIn time.Duration) (work, pause time.Duration) } var diff time.Duration - if workIn <= (9*time.Hour) && pauseIn < 30*time.Minute { + + if (workIn+pauseIn) <= (9*time.Hour+30*time.Minute) && pauseIn <= 30*time.Minute { diff = 30*time.Minute - pauseIn } else if pauseIn < 45*time.Minute { diff = 45*time.Minute - pauseIn diff --git a/Backend/models/workDay_test.go b/Backend/models/workDay_test.go index a3fe7be..7159ef9 100644 --- a/Backend/models/workDay_test.go +++ b/Backend/models/workDay_test.go @@ -30,18 +30,63 @@ func TestWorkdayWorktimeDay(t *testing.T) { }{ { testName: "Bookings6hrs", - bookings: testBookings6hrs, - expectedTime: time.Hour * 6, + bookings: testBookings6hrs, //work 6h + expectedTime: time.Hour * 6, //pause 0 }, { testName: "Bookings8hrs", - bookings: testBookings8hrs, - expectedTime: time.Hour*7 + time.Minute*30, + bookings: testBookings8hrs, //work 8 pause 0 + expectedTime: time.Hour*7 + time.Minute*30, //pause 30 --> corrected }, { testName: "Bookings10hrs", - bookings: testBookings10hrs, - expectedTime: time.Hour*9 + time.Minute*15, + bookings: testBookings10hrs, //work 10 pause 0 + expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> corrected + }, + { + testName: "Booking 6h with 30 min Break", + bookings: testBookings6hrsBreak30min, //work 6 pause 30 + expectedTime: time.Hour * 6, //pause 30 --> bc real pause + }, + { + testName: "Booking 6h 10min with 30 min Break", + bookings: testBookings610hrsBreak30min, //work 6 10 pause 30 + expectedTime: time.Hour*6 + time.Minute*10, //pause 30 --> real pause + }, + { + testName: "Booking 9h with 30 min Break", + bookings: testBookings9hrsBreak30min, //work 9 pause 30 + expectedTime: time.Hour * 9, //pause 30 --> real pause + }, + { + testName: "Booking 9h 30min", + bookings: testBookings930hrs, //work 9 30 pause 0 + expectedTime: time.Hour * 9, //pause 30 --> corrected + }, + { + testName: "Booking 9h 40min with 30min Break", + bookings: testBookings910hrsBreak30min, //work 9 10 pause 30 + expectedTime: time.Hour*8 + time.Minute*55, //pause 45 --> real + corrected + }, + { + testName: "Booking 9h 40min with 35min Break", + bookings: testBookings910hrsBreak35min, //work 9 10 pause 35 + expectedTime: time.Hour * 9, //pause 45 --> real + corrected + }, + { + testName: "Booking 9h 45min", + bookings: testBookings945hrs, //work 9 45 pause 0 + expectedTime: time.Hour * 9, //pause 45 --> corrected + }, + { + testName: "Booking 10h Break 45min", + bookings: testBookings10hrsBreak45min, //work 9 15 pause 45 + expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> real + }, + { + testName: "Booking 10h 30min Break 45min", + bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45 + expectedTime: time.Hour*9 + time.Minute*45, //pause 45 --> real }, } @@ -113,6 +158,51 @@ func TestWorkdayPausetimeDay(t *testing.T) { bookings: testBookings10hrs, expectedTime: time.Minute * 45, }, + { + testName: "Booking 6h with 30 min Break", + bookings: testBookings6hrsBreak30min, //work 6 pause 30 + expectedTime: time.Minute * 30, //pause 30 --> bc real pause + }, + { + testName: "Booking 6h 10min with 30 min Break", + bookings: testBookings610hrsBreak30min, //work 6 10 pause 30 + expectedTime: time.Minute * 30, //pause 30 --> real pause + }, + { + testName: "Booking 9h with 30 min Break", + bookings: testBookings9hrsBreak30min, //work 9 pause 30 + expectedTime: time.Minute * 30, //pause 30 --> real pause + }, + { + testName: "Booking 9h 30min", + bookings: testBookings930hrs, //work 9 30 pause 0 + expectedTime: time.Minute * 30, //pause 30 --> corrected + }, + { + testName: "Booking 9h 40min with 30min Break", + bookings: testBookings910hrsBreak30min, //work 9 10 pause 30 + expectedTime: time.Minute * 45, //pause 45 --> real + corrected + }, + { + testName: "Booking 9h 40min with 35min Break", + bookings: testBookings910hrsBreak35min, //work 9 10 pause 35 + expectedTime: time.Minute * 45, //pause 45 --> real + corrected + }, + { + testName: "Booking 9h 45min", + bookings: testBookings945hrs, //work 9 45 pause 0 + expectedTime: time.Minute * 45, //pause 45 --> corrected + }, + { + testName: "Booking 10h Break 45min", + bookings: testBookings10hrsBreak45min, //work 9 15 pause 45 + expectedTime: time.Minute * 45, //pause 45 --> real + }, + { + testName: "Booking 10h 30min Break 45min", + bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45 + expectedTime: time.Minute * 45, //pause 45 --> real + }, } for _, tc := range testCases { diff --git a/Readme.md b/Readme.md index eb15764..5d8e1c7 100644 --- a/Readme.md +++ b/Readme.md @@ -50,9 +50,100 @@ cd arbeitszeitmessung - `BACKUP_FOLDER` Pfad für DB Backup Datein - `LOG_PATH` Pfad für Audit Logs +## Administration: + +### Nutzer erstellen: + +Nutzerdaten erstellen: + +```sql +INSERT INTO "s_personal_daten" + ( + "personal_nummer", + "vorname", + "nachname", + "card_uid", + "geburtsdatum", + "geschlecht", + "adresse", + "plz", + "hauptbeschaeftigungs_ort", + "aktiv_beschaeftigt", + "vorgesetzter_pers_nr", + "arbeitszeit_min_start", + "arbeitszeit_max_ende", + "arbeitszeit_per_tag", + "arbeitszeit_per_woche", + ) +VALUES ( + 1, + 'Max', + 'Mustermann', + 'acde-edca', + '2003-02-01', + 1, + 'Musterstr. 42', + '00001', + 1, + true, + 123, + '07:00:00', + '20:00:00', + 8, + 40 + ); +``` + +Nutzerpasswort generieren (kann auch später als Passwort reset genutzt werden): + +```sql +INSERT INTO "user_password" + ("personal_nummer", "pass_hash") +VALUES (123, crypt('password', gen_salt('bf'))); +``` + +### Buchungstypen erstellen: + +Ohne definierte Anwesenheits und Abwesenheitstypen funktioniert die Anwendung nicht! + +Anwesenheiten: + +```sql +INSERT INTO "s_anwesenheit_typen" + ("anwesenheit_id", "anwesenheit_name") +VALUES (1, 'Büro'); +``` + +Abwesenheiten: + +```sql +INSERT INTO "s_abwesenheit_typen" + ("abwesenheit_id", "abwesenheit_name", "arbeitszeit_equivalent") +VALUES (1, 'Urlaub', 100); +``` + +### Feiertage erstellen: + +Die gesetzlichen Feiertage für Deutschland/Sachsen werden automatisch mit der Route `auto/feiertage` für das aktuelle Kalenderjahr erzeugt. Um weitere Unternehmensspezifische Feiertage (z.B. 24.12. oder 31.12.) mit in die Liste der Feiertage aufzunehmen, müssen diese manuell erstellt werden. + +```sql +INSERT INTO "s_feiertage" + ("datum", "name", "arbeitszeit_equivalent", "wiederholen") +VALUES ('2026-12-24', 'Helligabend', 50, 1); +``` + +Wenn `wiederholen` == 1 wird der Feiertag automatisch beim Aufruf von `auto/feiertage` mit ins nächste Jahr (am selben Datum) übernommen. + +Das Feld `arbeitszeit_equivalent` `arbeitszeit_equivalent` ist die prozentuelle Zeit am Tag welche durch diesen Eintrag eingenommen wird. (dies gilt auch für die [Buchungstypen](#buchungstypen-erstellen)) + +Alle weiteren Tabellen sollte ausschließlich über die Weboberfläche oder per API befüllt werden. + +--- + # Filestrukture ``` + ├── Backend (Webserver) │   ├── doc (Templates for Document Creator --> typst used to create PDF Reports) │   │   ├── static From 8bb17775192c3650c141fa2fa4161fb6e7f353a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Wed, 25 Feb 2026 01:02:15 +0100 Subject: [PATCH 12/14] feat: booking can only in between specified hours every booking happening outside these hours will be clamped to the hours also added few more config options + regex filters --- Backend/endpoints/time-create.go | 3 +- Backend/main.go | 9 +++ Backend/models/booking.go | 68 ++++++++++++++++++++--- Backend/models/user.go | 92 +++++++++++++++---------------- Backend/models/workDay.go | 8 ++- Backend/templates/basePages.templ | 2 + Docker/env.example | 15 +++-- 7 files changed, 129 insertions(+), 68 deletions(-) diff --git a/Backend/endpoints/time-create.go b/Backend/endpoints/time-create.go index 6e3097b..4478691 100644 --- a/Backend/endpoints/time-create.go +++ b/Backend/endpoints/time-create.go @@ -10,7 +10,6 @@ import ( "errors" "log" "net/http" - "time" ) // Relevant for arduino inputs -> creates new Booking from get and put method @@ -40,7 +39,7 @@ func createBooking(w http.ResponseWriter, r *http.Request) { } booking := (*models.Booking).FromUrlParams(nil, r.URL.Query()) - booking.Timestamp = time.Now() + // booking.Timestamp = time.Now() if booking.Verify() { err := booking.Insert() if errors.Is(models.SameBookingError{}, err) { diff --git a/Backend/main.go b/Backend/main.go index 761cf17..49fca64 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -52,6 +52,8 @@ func main() { defer models.DB.(*sql.DB).Close() + models.Options = configure() + if helper.GetEnv("GO_ENV", "production") != "debug" { err = Migrate() if err != nil { @@ -114,3 +116,10 @@ func loggingMiddleware(next http.Handler) http.Handler { slog.Info("Completet Request", slog.String("Time", time.Since(start).String())) }) } + +func configure() models.BookingOptions { + return models.BookingOptions{ + AllowOutOfBounds: helper.GetEnv("BOOKING_OUT_OF_BOUNDS", "false") == "true", + AllowUnknownUser: helper.GetEnv("BOOKING_FOR_UNKNOWN_USER", "false") == "true", + } +} diff --git a/Backend/models/booking.go b/Backend/models/booking.go index 8072a0f..fb947ca 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -39,6 +39,11 @@ type Booking struct { Valid bool `json:"valid"` } +type BookingOptions struct { + AllowOutOfBounds bool + AllowUnknownUser bool +} + type IDatabase interface { Prepare(query string) (*sql.Stmt, error) Exec(query string, args ...any) (sql.Result, error) @@ -46,6 +51,8 @@ type IDatabase interface { var DB IDatabase +var Options BookingOptions + func (b *Booking) NewBooking(cardUid string, gereatId int16, checkInOut int16, typeId int8) Booking { bookingType, err := GetBookingTypeById(typeId) if err != nil { @@ -92,10 +99,44 @@ func (b *Booking) Verify() bool { } else { b.BookingType.Name = bookingType.Name } + + user, err := GetUserByCardUID(b.CardUID) + if err == sql.ErrNoRows { + log.Println("Cannot find user with given CardUID") + return Options.AllowUnknownUser // if allow do not fail verify if not allow fail verify + } + + if err != nil { + slog.Error("Cannot get user from CardUID", "error", err) + return false + } + + if bookingOutOfBounds(b, &user) { + auditLog, closeLog := logs.NewAudit() + defer closeLog() + if !Options.AllowOutOfBounds { + return false + } + + oldTime := b.Timestamp + if oldTime.IsZero() { + oldTime = time.Now() + } + if b.CheckInOut%2 == 1 && b.CheckInOut < 200 { //kommen Booking + b.Timestamp = user.ArbeitMinStartTime(oldTime) + } else { + b.Timestamp = user.ArbeitMaxEndeTime(oldTime) + } + auditLog.Printf("Buchung (%s) von '%s' außerhalb der regulaeren Zeit. Verschieben der Zeit %s -> %s", b.GetBookingType(), user.CardUID, oldTime.Format(time.TimeOnly), b.Timestamp.Format(time.TimeOnly)) + slog.Info("Booking is out of work time bounds, setting time to match worktime bounds", "new_time", b.Timestamp.String(), "old_time", oldTime) + } return true } func (b *Booking) Insert() error { + if !b.Timestamp.IsZero() { + return b.InsertWithTimestamp() + } if !checkLastBooking(*b) { return SameBookingError{} } @@ -224,20 +265,21 @@ func (b *Booking) Update(nb Booking) { b.GeraetID = nb.GeraetID } if b.Timestamp != nb.Timestamp { - auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format("15:04"), nb.Timestamp.Format("15:04)")) + auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format(time.TimeOnly), nb.Timestamp.Format(time.TimeOnly)) b.Timestamp = nb.Timestamp } } func checkLastBooking(b Booking) bool { var check_in_out int - slog.Info("Checking with timestamp:", "timestamp", b.Timestamp.String()) - stmt, err := DB.Prepare((`SELECT check_in_out FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp" <= $2 ORDER BY "timestamp" DESC LIMIT 1;`)) + var timestamp time.Time + slog.Debug("Checking with timestamp:", "timestamp", b.Timestamp) + stmt, err := DB.Prepare((`SELECT check_in_out, timestamp FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp" <= $2 ORDER BY "timestamp" DESC LIMIT 1;`)) if err != nil { log.Fatalf("Error preparing query: %v", err) return false } - err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out) + err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out, ×tamp) slog.Info("Checking last bookings check_in_out", "Check", check_in_out) if err == sql.ErrNoRows { return true @@ -246,9 +288,13 @@ func checkLastBooking(b Booking) bool { log.Println("Error checking last booking: ", err) return false } + if int16(check_in_out)%2 == b.CheckInOut%2 { return false } + if timestamp.Equal(b.Timestamp) { + return false + } return true } @@ -257,8 +303,6 @@ func (b *Booking) UpdateTime(newTime time.Time) { if hour == b.Timestamp.Hour() && minute == b.Timestamp.Minute() { return } - // TODO: add check for time overlap - var newBooking Booking newBooking.Timestamp = time.Date(b.Timestamp.Year(), b.Timestamp.Month(), b.Timestamp.Day(), hour, minute, 0, 0, b.Timestamp.Location()) if b.CheckInOut < 3 { @@ -268,14 +312,11 @@ func (b *Booking) UpdateTime(newTime time.Time) { newBooking.CheckInOut = 4 } b.Update(newBooking) - // TODO Check verify if b.Verify() { b.Save() } else { log.Println("Cannot save updated booking!", b.ToString()) } - // b.Verify() - // b.Save() } func (b *Booking) ToString() string { @@ -327,3 +368,12 @@ func GetBookingTypesCached() []BookingType { } return types.([]BookingType) } + +func bookingOutOfBounds(b *Booking, u *User) bool { + bookingTime := b.Timestamp + if b.Timestamp.IsZero() { + bookingTime = time.Now() + } + res := bookingTime.Before(u.ArbeitMinStartTime(bookingTime)) || bookingTime.After(u.ArbeitMaxEndeTime(bookingTime)) + return res +} diff --git a/Backend/models/user.go b/Backend/models/user.go index cf74fd9..f6fd885 100644 --- a/Backend/models/user.go +++ b/Backend/models/user.go @@ -28,6 +28,8 @@ type User struct { ArbeitszeitPerTag float32 //`json:"arbeitszeit_per_tag"` ArbeitszeitPerWoche float32 //`json:"arbeitszeit_per_woche"` Overtime time.Duration + ArbeitMinStart time.Time + ArbeitMaxEnde time.Time } func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, error) { @@ -65,35 +67,41 @@ func (u *User) GetReportedOvertime(startDate time.Time) (time.Duration, error) { return overtime, nil } -func GetAllUsers() ([]User, error) { - qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname,arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten;`)) - var users []User - if err != nil { - return users, err - } - defer qStr.Close() - rows, err := qStr.Query() - if err != nil { - return users, err - } - defer rows.Close() - for rows.Next() { +func GetUserByCardUID(cardUid string) (User, error) { + var user User - var user User - if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil { - log.Println("Error creating user!", err) - continue - } - users = append(users, user) + qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE card_uid = $1;`)) + if err != nil { + return user, err } - if err = rows.Err(); err != nil { - return users, nil + err = qStr.QueryRow(cardUid).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde) + + if err != nil { + return user, err } - return users, nil + return user, nil } -func (u *User) GetAll() ([]User, error) { - qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname FROM s_personal_daten;`)) +func (u *User) ArbeitMinStartTime(date time.Time) time.Time { + if date.Hour() > 0 { + date = date.Truncate(24 * time.Hour).Add(-time.Hour) + } + date = date.Truncate(time.Hour) + slog.Info("Date truncate", "date", date) + return date.Add(time.Hour*time.Duration(u.ArbeitMinStart.Hour()) + time.Minute*time.Duration(u.ArbeitMinStart.Minute())) +} + +func (u *User) ArbeitMaxEndeTime(date time.Time) time.Time { + if date.Hour() > 0 { + date = date.Truncate(24 * time.Hour).Add(-time.Hour) + } + date = date.Truncate(time.Hour) + slog.Info("Date truncate", "date", date) + return date.Add(time.Hour*time.Duration(u.ArbeitMaxEnde.Hour()) + time.Minute*time.Duration(u.ArbeitMaxEnde.Minute())) +} + +func GetAllUsers() ([]User, error) { + qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten;`)) var users []User if err != nil { return users, err @@ -107,7 +115,7 @@ func (u *User) GetAll() ([]User, error) { for rows.Next() { var user User - if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name); err != nil { + if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil { log.Println("Error creating user!", err) continue } @@ -167,11 +175,11 @@ func (u *User) CheckOut() error { func GetUserByPersonalNr(personalNummer int) (User, error) { var user User - qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = $1;`)) + qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = $1;`)) if err != nil { return user, err } - err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche) + err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde) if err != nil { return user, err @@ -185,7 +193,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) { return users, errors.New("No personalNumbers provided") } - qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`)) + qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`)) if err != nil { return users, err } @@ -200,7 +208,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) { defer rows.Close() for rows.Next() { var user User - if err := rows.Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil { + if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil { return users, err } users = append(users, user) @@ -246,6 +254,7 @@ func (u *User) ChangePass(password, newPassword string) (bool, error) { } func (u *User) GetTeamMembers() ([]User, error) { + var teamMemberPNrs []int var teamMembers []User qStr, err := DB.Prepare(`SELECT personal_nummer FROM s_personal_daten WHERE vorgesetzter_pers_nr = $1 ORDER BY "nachname";`) if err != nil { @@ -261,12 +270,16 @@ func (u *User) GetTeamMembers() ([]User, error) { for rows.Next() { var personalNr int err := rows.Scan(&personalNr) - user, err := GetUserByPersonalNr(personalNr) + teamMemberPNrs = append(teamMemberPNrs, personalNr) if err != nil { log.Println("Error getting user!") return teamMembers, err } - teamMembers = append(teamMembers, user) + } + teamMembers, err = GetUserByPersonalNrMulti(teamMemberPNrs) + if err != nil { + log.Println("Error getting users!") + return teamMembers, err } return teamMembers, nil @@ -343,22 +356,6 @@ LIMIT 1; return lastSub } -func (u *User) GetFromCardUID(card_uid string) (User, error) { - user := User{} - var err error - - qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag FROM s_personal_daten WHERE card_uid = $1;`)) - if err != nil { - return user, err - } - err = qStr.QueryRow(card_uid).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag) - - if err != nil { - return user, err - } - return user, nil -} - func (u *User) IsSuperior(e User) bool { var isSuperior int qStr, err := DB.Prepare(`SELECT COUNT(1) FROM s_personal_daten WHERE personal_nummer = $1 AND vorgesetzter_pers_nr = $2`) @@ -372,7 +369,6 @@ func (u *User) IsSuperior(e User) bool { return false } return isSuperior == 1 - } func getMonday(ts time.Time) time.Time { diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 7a6e6e3..ff3df58 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -424,10 +424,12 @@ GROUP BY // returns bool wheter the workday was ended with an automatic logout func (d *WorkDay) RequiresAction() bool { - if len(d.Bookings) == 0 { - return false + for i := range d.Bookings { + if d.Bookings[i].CheckInOut > 250 { + return true + } } - return d.Bookings[len(d.Bookings)-1].CheckInOut == 254 + return false } func (d *WorkDay) GetDayProgress(u User) int8 { diff --git a/Backend/templates/basePages.templ b/Backend/templates/basePages.templ index 7dd131c..8e0b79f 100644 --- a/Backend/templates/basePages.templ +++ b/Backend/templates/basePages.templ @@ -61,6 +61,8 @@ templ SettingsPage(status int) {

Nutzername: { user.Vorname } { user.Name }

Personalnummer: { user.PersonalNummer }

+

Frühester Arbeitsbegin: { user.ArbeitMinStart.Format("15:06") } Uhr

+

Spätester Arbeitsende: { user.ArbeitMaxEnde.Format("15:06") } Uhr

Arbeitszeit pro Tag: { helper.FormatDuration(user.ArbeitszeitProTag()) }

Arbeitszeit pro Woche: { helper.FormatDuration(user.ArbeitszeitProWoche()) }

diff --git a/Docker/env.example b/Docker/env.example index 1a23768..bee3803 100644 --- a/Docker/env.example +++ b/Docker/env.example @@ -1,13 +1,16 @@ -POSTGRES_USER=root # Postgres ADMIN Nutzername +POSTGRES_USER=root # Postgres ADMIN Nutzername. regex:^\w+$ POSTGRES_PASSWORD=very_secure # Postgres ADMIN Passwort -POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung) +POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung). regex:^\w+$ POSTGRES_API_PASS=password # Postgres API Passwort (für Arbeitszeitmessung) POSTGRES_PATH=__ROOT__/DB # Datebank Pfad (relativ zu Docker Ordner oder absoluter pfad mit /...) -POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name -POSTGRES_PORT=5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$ +POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name. regex:^[a-z]+$ +POSTGRES_PORT=5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$ TZ=Europe/Berlin # Zeitzone -API_TOKEN=dont_access # API Token für ESP Endpoints +API_TOKEN=dont_access # API Token für ESP32 Endpoints WEB_PORT=8000 # Port unter welchem Webserver erreichbar ist. regex:^[0-9]{1,5}$ LOG_PATH=__ROOT__/logs # Pfad für Audit Logs -LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen +LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen. regex:^(debug|info|warn|error)$ BACKUP_FOLDER=__ROOT__/backup # Pfad für DB Backup Datein + +BOOKING_OUT_OF_BOUNDS=true # Buchungen außerhalb der festgelegten Arbeitszeit erlauben und auf Arbeitszeit anpassen. regex:^(true|false)$ +BOOKING_FOR_UNKNOWN_USER=true # Buchungen mit unbekannter CardUID erlauben. regex:^(true|false)$ From b12a467ef933fd8925cf42b1e562750ec0ce363d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Thu, 26 Feb 2026 21:43:40 +0100 Subject: [PATCH 13/14] updated install script to also reconfigure/update everything --- .gitignore | 3 +- Cron/autoHolidays.sh | 1 + install.sh | 486 +++++++++++++++++++++++++++---------------- 3 files changed, 313 insertions(+), 177 deletions(-) diff --git a/.gitignore b/.gitignore index a1a3871..70f663e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,8 @@ DB/pg_data .env.* .env -!.env.example + +Docker/config .idea .vscode diff --git a/Cron/autoHolidays.sh b/Cron/autoHolidays.sh index 50607b4..2c937ca 100755 --- a/Cron/autoHolidays.sh +++ b/Cron/autoHolidays.sh @@ -1,3 +1,4 @@ +# cron-timing: 01 00 01 01 * # Calls endpoint to write all public Holidays for the current year inside a database. port=__PORT__ curl localhost:$port/auto/feiertage diff --git a/install.sh b/install.sh index eb7ae33..a31a638 100755 --- a/install.sh +++ b/install.sh @@ -1,193 +1,327 @@ #!/usr/bin/env bash +#©Tom Tröger 2026 set -e envFile=Docker/.env +envBkp=Docker/.env.old envExample=Docker/env.example -autoBackupScript=Cron/autoBackup.sh -autoHolidaysScript=Cron/autoHolidays.sh -autoLogoutScript=Cron/autoLogout.sh +cronFilePath=Cron +customCronFilePath=Docker/config/cron -echo "Checking Docker installation..." -if ! command -v docker >/dev/null 2>&1; then - echo "Docker not found. Install Docker? [y/N]" - read -r install_docker - if [[ "$install_docker" =~ ^[Yy]$ ]]; then - curl -fsSL https://get.docker.com | sh +autoBackupScript=autoBackup.sh +autoHolidaysScript=autoHolidays.sh +autoLogoutScript=autoLogout.sh + +function checkDocker() { + echo "Checking Docker installation..." + if ! command -v docker >/dev/null 2>&1; then + echo "Docker not found. Install Docker? [y/N]" + read -r install_docker + if [[ "$install_docker" =~ ^[Yy]$ ]]; then + curl -fsSL https://get.docker.com | sh + else + echo "Docker is required. Exiting." + exit 1 + fi + else + echo "Docker is already installed." + fi + + ########################################################################### + + echo "Checking Docker Compose..." + if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose plugin missing. You may need to update Docker." + exit 1 + fi +} +########################################################################### + +function setupConfig() { + local reconfig=false + if [ $# -gt 0 ]; then + if ask_reconfig $1 "Reconfigure .env File?" + then + reconfig=true else - echo "Docker is required. Exiting." - exit 1 + return 0 fi -else - echo "Docker is already installed." -fi - -########################################################################### - -echo "Checking Docker Compose..." -if ! docker compose version >/dev/null 2>&1; then - echo "Docker Compose plugin missing. You may need to update Docker." - exit 1 -fi - -########################################################################### - -echo "Preparing .env file..." -if [ ! -f $envFile ]; then - if [ -f $envExample ]; then - echo ".env not found. Creating interactively from .env.example." - > $envFile - - while IFS= read -r line; do - - #ignore empty lines and comments - [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue - - - key=$(printf "%s" "$line" | cut -d '=' -f 1) - rest=$(printf "%s" "$line" | cut -d '=' -f 2-) - - # extract inline comment portion - comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p') - raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//') - default_value=$(printf "%s" "$raw_val" | sed 's/"//g') - - # Replace __ROOT__ with script pwd - default_value="${default_value/__ROOT__/$(pwd)}" - - regex="" - if [[ "$comment" =~ regex:(.*)$ ]]; then - regex="${BASH_REMATCH[1]}" - fi - - comment=$(printf "%s" "$comment" | sed 's/ regex:.*//') - - while true; do - if [ -z "$comment" ]; then - printf "Value for $key (default: $default_value" - else - printf "Value for $key - $comment (default: $default_value" - fi - if [ -n "$regex" ]; then - printf ", must match: %s" "$regex" - fi - printf "):\n" - - read user_input < /dev/tty - - # empty input -> take default - [ -z "$user_input" ] && user_input="$default_value" - - printf "\e[A$user_input\n" - - # validate - if [ -n "$regex" ]; then - if [[ "$user_input" =~ $regex ]]; then - echo "$key=$user_input" >> $envFile - break - else - printf "Invalid value. Does not match regex: %s\n" "$regex" - continue - fi - else - echo "$key=$user_input" >> $envFile - break - fi - done - - done < $envExample - - echo ".env created." - else - echo "No .env or .env.example found." - echo "Creating an empty .env file for manual editing." - touch $envFile - fi -else - echo "Using existing .env. (found at $envFile)" -fi - -########################################################################### - -LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2) -if [ -z "$LOG_PATH" ]; then - echo "LOG_PATH not found in .env using default $(pwd)/logs" - LOG_PATH=$(pwd)/logs -fi -mkdir -p $LOG_PATH -echo "Created logs folder at $LOG_PATH" - -########################################################################### - -echo -e "\n\n" -echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]" -read -r setup_cron -if [[ "$setup_cron" =~ ^[Yy]$ ]]; then - WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2) - if [ -z "$WEB_PORT" ]; then - echo "WEB_PORT not found in .env using default 8000" - WEB_PORT=8000 - fi - - POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2) - if [ -z "$POSTGRES_DB" ]; then - echo "POSTGRES_DB not found in .env using default arbeitszeitmessung" - POSTGRES_DB="arbeitszeitmessung" - fi - - BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2) - if [ -z "$BACKUP_FOLDER" ]; then - echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup" - BACKUP_FOLDER="$(pwd)/backup" - fi - - sed -i "s/__PORT__/$WEB_PORT/" $autoHolidaysScript - sed -i "s/__PORT__/$WEB_PORT/" $autoLogoutScript - sed -i "s/__DATABASE__/$POSTGRES_DB/" $autoBackupScript - sed -i "s/__BACKUP_FOLDER__/$BACKUP_FOLDER" $autoBackupScript - - chmod +x $autoBackupScript $autoHolidaysScript $autoLogoutScript - - # echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!" - echo "Adding rules to crontab." - - cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX) - - for file in Cron/*; do - cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//') - - if [ -z "$cron_timing" ]; then - echo "No cron-timing found in $file, so it's not added to crontab." - continue + fi + echo -e "\r\n==================================================\r\n" + echo "Preparing .env file..." + if [ ! -f $envFile ] || [ $reconfig == true ]; then + if [ -f $envExample ]; then + if [ $reconfig == true ]; then + echo "Reconfiguring env file. Backup stored at $envBkp" + echo "All previous values will be used as defaults!" + cp $envFile $envBkp + else + echo ".env not found. Creating interactively from .env.example." fi + > $envFile - ( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab - - echo "Added entry to crontab: $cron_timing $(pwd)/$file." - sleep 2 - done - if systemctl is-active --quiet cron.service ; then - echo "cron.service is running. Everything should be fine now." - else - echo "cron.service is not running. Please start and enable cron.service." - echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service" - fi + while IFS= read -r line; do -else - echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!" -fi + #ignore empty lines and comments + [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue + + local key=$(printf "%s" "$line" | cut -d '=' -f 1) + local rest=$(printf "%s" "$line" | cut -d '=' -f 2-) + + # extract inline comment portion + local comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p') + local raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//') + + local default_value=$(printf "%s" "$raw_val" | sed 's/"//g') + + if [ $reconfig == true ]; then + local previous_value=$(grep -E "^$key=" $envBkp | cut -d= -f2) + if [ -n "$previous_value" ]; then + default_value=$previous_value + fi + fi + + # Replace __ROOT__ with script pwd + local default_value="${default_value/__ROOT__/$(pwd)}" + + regex="" + if [[ "$comment" =~ regex:(.*)$ ]]; then + regex="${BASH_REMATCH[1]}" + fi + + comment=$(printf "%s" "$comment" | sed 's/ regex:.*//') + + while true; do + if [ -z "$comment" ]; then + printf "Value for $key (default: $default_value" + else + printf "Value for $key - $comment (default: $default_value" + fi + if [ -n "$regex" ]; then + printf ", must match: %s" "$regex" + fi + printf "):\n" + + read user_input < /dev/tty + + # empty input -> take default + [ -z "$user_input" ] && user_input="$default_value" + + printf "\e[A$user_input\n" + + # validate + if [ -n "$regex" ]; then + if [[ "$user_input" =~ $regex ]]; then + echo "$key=$user_input" >> $envFile + break + else + printf "Invalid value. Does not match regex: %s\n" "$regex" + continue + fi + else + echo "$key=$user_input" >> $envFile + break + fi + done + + done < $envExample + + echo ".env created." + else + echo "No .env or .env.example found." + echo "Creating an empty .env file for manual editing." + touch $envFile + fi + else + echo "Using existing .env. (found at $envFile)" + fi +} ########################################################################### -echo -e "\n\n" -echo "Start containers with docker compose up -d? [y/N]" -read -r start_containers -if [[ "$start_containers" =~ ^[Yy]$ ]]; then - cd Docker - docker compose up -d - echo "Containers started." -else - echo "You can start them manually with: docker compose up -d" -fi +function setupFolders(){ + if [ $# -gt 0 ]; then + if ! ask_reconfig $1 "Recreate Folders?" + then + return 0 + fi + fi + LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2) + if [ -z "$LOG_PATH" ]; then + echo "LOG_PATH not found in .env using default $(pwd)/logs" + LOG_PATH=$(pwd)/logs + fi + if [ ! -d "$LOG_PATH" ]; then + mkdir -p $LOG_PATH + echo "Created logs folder at $LOG_PATH" + fi -echo "Installation finished, you can re-run the script any time!" + POSTGRES_PATH=$(grep -E '^POSTGRES_PATH=' $envFile | cut -d= -f2) + if [ -z "$POSTGRES_PATH" ]; then + echo "POSTGRES_PATH not found in .env using default $(pwd)/DB" + POSTGRES_PATH=$(pwd)/DB + fi + if [ ! -d "$POSTGRES_PATH" ]; then + mkdir -p $POSTGRES_PATH + echo "Created DB folder at $POSTGRES_PATH" + fi + + BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2) + if [ -z "$BACKUP_FOLDER" ]; then + echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup" + BACKUP_FOLDER=$(pwd)/backup + fi + if [ ! -d "$BACKUP_FOLDER" ]; then + mkdir -p $BACKUP_FOLDER + echo "Created backup folder at $BACKUP_FOLDER" + fi +} +########################################################################### + +function setupCron(){ + echo -e "\r\n==================================================\r\n" + echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]" + read -r setup_cron + if [[ "$setup_cron" =~ ^[Yy]$ ]]; then + echo "Copying custom cron files to $customCronFilePath" + mkdir -p "$customCronFilePath" + + if [ ! -s "$customCronFilePath/$autoBackupScript" ];then + cp "$cronFilePath/$autoBackupScript" "$customCronFilePath/$autoBackupScript" + echo "Copied $autoBackupScript" + fi + + if [ ! -s "$customCronFilePath/$autoLogoutScript" ];then + cp "$cronFilePath/$autoLogoutScript" "$customCronFilePath/$autoLogoutScript" + echo "Copied $autoLogoutScript" + fi + + if [ ! -s "$customCronFilePath/$autoHolidaysScript" ];then + cp "$cronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoHolidaysScript" + echo "Copied $autoHolidaysScript" + fi + + WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2) + if [ -z "$WEB_PORT" ]; then + echo "WEB_PORT not found in .env using default 8000" + WEB_PORT=8000 + fi + + POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2) + if [ -z "$POSTGRES_DB" ]; then + echo "POSTGRES_DB not found in .env using default arbeitszeitmessung" + POSTGRES_DB="arbeitszeitmessung" + fi + + BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2) + if [ -z "$BACKUP_FOLDER" ]; then + echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup" + BACKUP_FOLDER="$(pwd)/backup" + fi + + sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoHolidaysScript && \ + sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoLogoutScript && \ + sed -i "s|__DATABASE__|$POSTGRES_DB|" $customCronFilePath/$autoBackupScript && \ + sed -i "s|__BACKUP_FOLDER__|$BACKUP_FOLDER|" $customCronFilePath/$autoBackupScript + + chmod +x "$customCronFilePath/$autoBackupScript" "$customCronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoLogoutScript" + + # echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!" + echo "Adding rules to crontab." + + cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX) + pwd + + for file in $customCronFilePath/*; do + cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//') + + if [ -z "$cron_timing" ]; then + echo "No cron-timing found in $file, so it's not added to crontab." + continue + fi + + ( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab - + echo "Added entry to crontab: $cron_timing $(pwd)/$file." + done + + if systemctl is-active --quiet cron.service ; then + echo "cron.service is running. Everything should be fine now." + else + echo "cron.service is not running. Please start and enable cron.service." + echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service" + fi + + else + echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!" + fi +} +########################################################################### + +function startContainer(){ + echo -e "\r\n==================================================\r\n" + echo "Start containers with docker compose up -d? [y/N]" + read -r start_containers + if [[ "$start_containers" =~ ^[Yy]$ ]]; then + cd Docker + docker compose up -d + echo "Containers started." + else + echo "You can start them manually with: docker compose up -d" + fi +} +########################################################################### + +function help(){ + echo "Installer Script für Arbeitszeitmessung Software" + echo -e "\r\n==================================================\r\n" + echo "Nutzung: ./install.sh [options]" + echo -e "\r\n==================================================\r\n" + echo "Optionen:" + echo " -h zeigt diese Übersicht" + echo " -c .env Datei bearbeiten/aktualisieren && cron neu configurieren" + echo -e "\r\n==================================================" +} +########################################################################### + +function main(){ + echo -e "================Arbeitszeitmessung================\r\n" + if [ $# -gt 0 ];then + if [ $1 == reconfig ]; then + echo -e "================Reconfiguring================\r\n" + setupConfig $1 + setupFolders $1 + setupCron $1 + fi + else + checkDocker + setupConfig + setupFolders + setupCron + startContainer + fi + echo "Installation finished, you can re-run the script any time!" +} +########################################################################### + +function ask_reconfig(){ + echo -e "\r\n==================================================\r\n" + echo "$2 [y/N]" + read -r do_reconfig + + [[ "$do_reconfig" =~ ^[Yy]$ ]] && return # true + echo "Skipping..." + return 1 +} +########################################################################### + +while getopts ":hc" opt; do + case $opt in + h) help; exit 0 ;; + c) main reconfig; exit 0 ;; + *) echo "Ungültiges Argument"; exit 1 ;; + esac +done + +main From c29a952e1daa481974ad5c27d17ae2b29ce1dff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Tr=C3=B6ger?= Date: Fri, 27 Feb 2026 16:18:28 +0100 Subject: [PATCH 14/14] fix: changed backup filename for better sorting of backups --- Cron/autoBackup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cron/autoBackup.sh b/Cron/autoBackup.sh index 1be8589..2aa0989 100755 --- a/Cron/autoBackup.sh +++ b/Cron/autoBackup.sh @@ -1,6 +1,6 @@ # cron-timing: 05 01 * * 1 container_name="arbeitszeitmessung-main-db-1" -filename=backup-$(date '+%d%m%Y').sql +filename=backup-$(date '+%Y%m%d').sql backup_folder=__BACKUP_FOLDER__ database_name=__DATABASE__ docker exec $container_name pg_dump $database_name > $backup_folder/$filename