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/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) } } diff --git a/Backend/endpoints/pdf-create.go b/Backend/endpoints/pdf-create.go index b93be94..b680a16 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 -} +var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/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,75 @@ 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) + 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) + 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,37 +175,49 @@ 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) - 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()) + // 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 _, 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 - } - workHours += tmpvirtualHours - } - worktimeBalance := workHours - targetHoursThisMonth - typstDays, err := convertDaysToTypst(workDaysThisMonth, employee) + 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 += week.WorktimeVirtual + 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) + if err != nil { + slog.Error("Error converting days into typst", "error", err) + continue + } + typstDays = append(typstDays, weekTypstDays...) + } + 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{ 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: "", + 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 @@ -202,8 +228,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 +255,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 +297,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-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/endpoints/time.go b/Backend/endpoints/time.go index 09e66f1..227fb5f 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) @@ -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/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..49fca64 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) @@ -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/absence.go b/Backend/models/absence.go index 7c99e76..2246be3 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" ) @@ -61,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 } @@ -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 6704913..fb947ca 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -36,6 +36,12 @@ type Booking struct { Timestamp time.Time `json:"timestamp"` CounterId int `json:"counter_id"` BookingType BookingType `json:"anwesenheit_typ"` + Valid bool `json:"valid"` +} + +type BookingOptions struct { + AllowOutOfBounds bool + AllowUnknownUser bool } type IDatabase interface { @@ -45,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 { @@ -91,31 +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) 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 !b.Timestamp.IsZero() { + return b.InsertWithTimestamp() + } if !checkLastBooking(*b) { return SameBookingError{} } @@ -208,7 +229,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" @@ -244,20 +265,22 @@ 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"::DATE <= $2::DATE 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 } @@ -265,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 } @@ -276,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 { @@ -287,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 { @@ -346,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/booking_test.go b/Backend/models/booking_test.go index c5f3e67..48f44f9 100644 --- a/Backend/models/booking_test.go +++ b/Backend/models/booking_test.go @@ -13,35 +13,250 @@ 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, }} + +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/compoundDay.go b/Backend/models/compoundDay.go index 323cea7..dd89665 100644 --- a/Backend/models/compoundDay.go +++ b/Backend/models/compoundDay.go @@ -17,6 +17,15 @@ type CompoundDay struct { DayParts []IWorkDay } +// IsSubmittedAndAccepted implements IWorkDay. +func (c *CompoundDay) IsSubmittedAndAccepted() bool { + var isSubmittedAndAccepted = true + for _, day := range c.DayParts { + isSubmittedAndAccepted = isSubmittedAndAccepted && day.IsSubmittedAndAccepted() + } + 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..53fa920 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 @@ -54,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 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..f6fd885 100644 --- a/Backend/models/user.go +++ b/Backend/models/user.go @@ -28,12 +28,14 @@ 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) { 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,50 +52,56 @@ 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 } 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 @@ -292,10 +305,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) @@ -311,22 +356,6 @@ func (u *User) GetLastWorkWeekSubmission() time.Time { 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`) @@ -340,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 e1fec9f..ff3df58 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 { @@ -46,11 +49,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 +61,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 +83,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 d.workTime == 0 && d.pauseTime == 0 && len(d.Bookings) > 0 { + 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 { @@ -105,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 @@ -140,12 +152,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()) @@ -158,7 +179,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 { @@ -178,97 +199,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, @@ -322,26 +394,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 @@ -349,10 +424,12 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { // 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 { @@ -363,3 +440,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/workDay_test.go b/Backend/models/workDay_test.go index 9ad0e6d..7159ef9 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) { @@ -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/Backend/models/workWeek.go b/Backend/models/workWeek.go index ef7d284..5a4ab18 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,25 +25,34 @@ 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 + Kurzarbeit time.Duration } type WeekStatus int8 const ( WeekStatusNone WeekStatus = iota + WeekStatusCorrected WeekStatusSent WeekStatusAccepted 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 { @@ -52,13 +62,25 @@ 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) + 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()) @@ -78,6 +100,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 @@ -86,25 +118,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 +244,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/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 4b2dd1d..004d9fa 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; @@ -241,6 +238,9 @@ .-my-1 { margin-block: calc(var(--spacing) * -1); } + .my-2 { + margin-block: calc(var(--spacing) * 2); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -308,6 +308,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; @@ -350,6 +376,9 @@ .block { display: block; } + .contents { + display: contents; + } .flex { display: flex; } @@ -380,6 +409,10 @@ width: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5); } + .size-6 { + width: calc(var(--spacing) * 6); + height: calc(var(--spacing) * 6); + } .h-2 { height: calc(var(--spacing) * 2); } @@ -463,27 +496,12 @@ .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 +552,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 +578,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 +592,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); } @@ -620,9 +622,6 @@ .p-2 { padding: calc(var(--spacing) * 2); } - .p-8 { - padding: calc(var(--spacing) * 8); - } .px-3 { padding-inline: calc(var(--spacing) * 3); } @@ -635,10 +634,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)); @@ -660,9 +655,6 @@ .text-black { color: var(--color-black); } - .text-neutral-300 { - color: var(--color-neutral-300); - } .text-neutral-500 { color: var(--color-neutral-500); } @@ -719,18 +711,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 +1004,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 { 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/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/Backend/templates/reportPage.templ b/Backend/templates/reportPage.templ index cf1e1cb..7e2ceb9 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 @@ -60,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)) }

@@ -170,10 +176,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

} diff --git a/Backend/templates/timeComponents.templ b/Backend/templates/timeComponents.templ index 7f2d066..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() { @@ -77,22 +83,22 @@ 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

- }
} 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 { 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/Cron/autoBackup.sh b/Cron/autoBackup.sh index 5430a1d..2aa0989 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 +filename=backup-$(date '+%Y%m%d').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/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/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 diff --git a/Docker/env.example b/Docker/env.example index 1a150dd..bee3803 100644 --- a/Docker/env.example +++ b/Docker/env.example @@ -1,12 +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=127.0.0.1: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)$ diff --git a/Readme.md b/Readme.md index c058ab8..5d8e1c7 100644 --- a/Readme.md +++ b/Readme.md @@ -2,128 +2,148 @@ [![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): +- 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 -![time](docs/images/time.png) +## Administration: -Ansicht der Führungskraft (/team): +### Nutzer erstellen: -![team](docs/images/team.png) +Nutzerdaten erstellen: -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 - } -] +```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 + ); ``` -Antwort `500` -Serverfehler +Nutzerpasswort generieren (kann auch später als Passwort reset genutzt werden): -#### [PUT] Anfrage - -Parameter: id (int) -Body: (veränderte Parameter) - -```json -{ - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z" -} +```sql +INSERT INTO "user_password" + ("personal_nummer", "pass_hash") +VALUES (123, crypt('password', gen_salt('bf'))); ``` -Antwort `200` +### Buchungstypen erstellen: -```json -{ - "cradID": "test_card", - "readerID": "mytest", - "bookingTyp": 1, - "loggedTime": "2024-09-05T08:51:12.670827Z", - "id": 6 -} +Ohne definierte Anwesenheits und Abwesenheitstypen funktioniert die Anwendung nicht! + +Anwesenheiten: + +```sql +INSERT INTO "s_anwesenheit_typen" + ("anwesenheit_id", "anwesenheit_name") +VALUES (1, 'Büro'); ``` -### Neue Buchung [/time/new] +Abwesenheiten: -#### [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 -} +```sql +INSERT INTO "s_abwesenheit_typen" + ("abwesenheit_id", "abwesenheit_name", "arbeitszeit_equivalent") +VALUES (1, 'Urlaub', 100); ``` -Antwort `409` Konflikt -Die vorherige Buchung am selben Tag hat den gleichen Buchungstyp +### 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 diff --git a/install.sh b/install.sh index 51c1ed7..a31a638 100755 --- a/install.sh +++ b/install.sh @@ -1,186 +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 - $comment (default: $default_value" - else - printf "Value for $key (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 -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 -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 "arbeitszeitmessung not found in .env using default arbeitszeitmessung" - POSTGRES_DB="arbeitszeitmessung" - fi - - sed -i "s/__PORT__/$WEB_PORT/" $autoHolidaysScript - sed -i "s/__PORT__/$WEB_PORT/" $autoLogoutScript - sed -i "s/__DATABASE__/$POSTGRES_DB/" $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" + while IFS= read -r line; do + + #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 +} +########################################################################### + +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 -else - echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!" -fi + 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