diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index c32282f..d2f0e60 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -2,56 +2,15 @@ name: Arbeitszeitmessung Deploy run-name: ${{ gitea.actor }} is building and deploying arbeitszeitmesssung on: push: - tags: "*" + tags: + - "*" + branches: + - main jobs: - testing: - name: Run Go Tests + webserver: + name: Build Webserver runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: root - POSTGRES_PASSWORD: password - POSTGRES_DB: arbeitszeitmessung - env: - POSTGRES_HOST: postgres - POSTGRES_USER: root - POSTGRES_PASSWORD: password - POSTGRES_DB: arbeitszeitmessung - POSTGRES_PORT: 5432 - RUNNER_TOOL_CACHE: /toolcache # Runner Tool Cache - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup go - uses: actions/setup-go@v5 - with: - go-version-file: Backend/go.mod - - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 - id: hash-go - with: - patterns: | - go.mod - go.sum - - name: cache go - id: cache-go - uses: actions/cache@v4 - with: - path: |- - /go_path - /go_cache - key: arbeitszeitmessung-${{ steps.hash-go.outputs.hash }} - restore-keys: |- - arbeitszeitmessung- - - name: Run Go Tests - run: cd Backend && go test ./... - build: - name: Build Go Image and Upload - runs-on: ubuntu-latest - needs: [testing] steps: - name: Checkout uses: actions/checkout@v4 @@ -65,12 +24,49 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: git.letsstein.de/tom/arbeitszeitmessung-webserver + tags: | + type=raw,value=latest + type=pep440,pattern={{version}} - name: Build and push uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 push: true context: Backend + tags: ${{ steps.meta.outputs.tags }} + document-creator: + name: Build Document Creator + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: git.letsstein.de + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: git.letsstein.de/tom/arbeitszeitmessung-doc-creator tags: | - git.letsstein.de/tom/arbeitszeitmessung:latest - git.letsstein.de/tom/arbeitszeitmessung:${{ github.ref_name }} + type=raw,value=latest + type=pep440,pattern={{version}} + - name: Build and push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + context: Backend + tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 2a3dfc4..80c1004 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -15,8 +15,8 @@ jobs: POSTGRES_DB: arbeitszeitmessung env: POSTGRES_HOST: postgres - POSTGRES_USER: root - POSTGRES_PASSWORD: password + POSTGRES_API_USER: root + POSTGRES_API_PASS: password POSTGRES_DB: arbeitszeitmessung POSTGRES_PORT: 5432 RUNNER_TOOL_CACHE: /toolcache # Runner Tool Cache diff --git a/.gitignore b/.gitignore index 13ae14c..cd09ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ node_modules atlas.hcl .scannerwork Backend/logs +.worktime.txt diff --git a/Backend/Makefile b/Backend/Makefile index 34598d0..c7f2e96 100644 --- a/Backend/Makefile +++ b/Backend/Makefile @@ -2,5 +2,16 @@ test: mkdir -p .test go test ./... -coverprofile=.test/coverage.out -json > .test/report.json -scan: - sonar-scanner -Dsonar.token=sqa_ca8394c93a728d6cff96703955288d8902c15200 +# scan: +# sonar-scanner -Dsonar.token=sqa_ca8394c93a728d6cff96703955288d8902c15200 + +# complete live run +live: + make -j2 live/templ live/tailwindcss + +live/templ: + templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." --open-browser=false + +live/tailwindcss: + npx --yes tailwindcss -i ./src/main.css -o ./static/css/styles.css --watch +#--minify diff --git a/Backend/database.go b/Backend/database.go index bad1b77..8394d1f 100644 --- a/Backend/database.go +++ b/Backend/database.go @@ -14,7 +14,8 @@ func OpenDatabase() (models.IDatabase, error) { dbName := helper.GetEnv("POSTGRES_DB", "arbeitszeitmessung") dbUser := helper.GetEnv("POSTGRES_API_USER", "api_nutzer") dbPassword := helper.GetEnv("POSTGRES_API_PASS", "password") + dbTz := helper.GetEnv("TZ", "Europe/Berlin") - connStr := fmt.Sprintf("postgres://%s:%s@%s:5432/%s?sslmode=disable&TimeZone=Europe/Berlin", dbUser, dbPassword, dbHost, dbName) + connStr := fmt.Sprintf("postgres://%s:%s@%s:5432/%s?sslmode=disable&TimeZone=%s", dbUser, dbPassword, dbHost, dbName, dbTz) return sql.Open("postgres", connStr) } diff --git a/Backend/endpoints/auto-feiertage.go b/Backend/endpoints/auto-feiertage.go new file mode 100644 index 0000000..6f32bca --- /dev/null +++ b/Backend/endpoints/auto-feiertage.go @@ -0,0 +1,43 @@ +package endpoints + +import ( + "arbeitszeitmessung/helper/paramParser" + "arbeitszeitmessung/models" + "log/slog" + "net/http" + "time" + + "github.com/wlbr/feiertage" +) + +func FeiertagsHandler(w http.ResponseWriter, r *http.Request) { + pp := paramParser.New(r.URL.Query()) + slog.Debug("Generating Holidays") + from := pp.ParseTimestampFallback("from", "2006", time.Now().AddDate(-1, 0, -time.Now().YearDay())) + to := pp.ParseTimestampFallback("to", "2006", time.Now().AddDate(0, 0, -time.Now().YearDay()+1)) + + var publicHolidays map[string]models.PublicHoliday = make(map[string]models.PublicHoliday) + + yearDelta := to.Year() - from.Year() + var holidays = feiertage.Sachsen(to.Year(), true) + for _, f := range holidays.Feiertage { + publicHolidays[f.Time.Format(time.DateOnly)] = models.NewHolidayFromFeiertag(f) + } + + repeatingHolidays, err := models.GetRepeatingHolidays(from, to.AddDate(0, 0, -1)) + if err != nil { + slog.Warn("Error getting holidays", slog.Any("Error", err)) + } + slog.Debug("Found repeating Holidays", "num", len(repeatingHolidays), "from", from, "to", to, "yeardelta", yearDelta) + for _, day := range repeatingHolidays { + day.Time = day.Time.AddDate(yearDelta, 0, 0) + publicHolidays[day.Date().Format(time.DateOnly)] = day + } + slog.Debug("Added repeating holidays", "num", len(holidays.Feiertage)) + for _, feiertag := range publicHolidays { + slog.Debug("Found holiday", "holiday", feiertag) + if err := feiertag.Insert(); err != nil { + slog.Warn("Error inserting Feiertag", slog.Any("Error", err)) + } + } +} diff --git a/Backend/endpoints/auto-kurzarbeit.go b/Backend/endpoints/auto-kurzarbeit.go new file mode 100644 index 0000000..10b2cd5 --- /dev/null +++ b/Backend/endpoints/auto-kurzarbeit.go @@ -0,0 +1,82 @@ +package endpoints + +import ( + "arbeitszeitmessung/helper" + "arbeitszeitmessung/helper/paramParser" + "arbeitszeitmessung/models" + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" +) + +func KurzarbeitFillHandler(w http.ResponseWriter, r *http.Request) { + helper.SetCors(w) + switch r.Method { + case "GET": + fillKurzarbeit(r, w) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func fillKurzarbeit(r *http.Request, w http.ResponseWriter) { + bookingTypeKurzarbeit, err := getKurzarbeitBookingType() + if err != nil { + slog.Info("Error getting BookingType Kurzarbeit %v\n", slog.Any("Error", err)) + } + users, err := models.GetAllUsers() + if err != nil { + slog.Info("Error getting user list %v\n", slog.Any("Error", err)) + } + + pp := paramParser.New(r.URL.Query()) + startDate := pp.ParseTimestampFallback("date", time.DateOnly, time.Now()) + + var kurzarbeitAdded int + + for _, user := range users { + days := models.GetDays(user, startDate, startDate.AddDate(0, 0, 1), false) + if len(days) == 0 { + continue + } + + day := days[len(days)-1] + if !day.IsKurzArbeit() || !day.IsWorkDay() { + continue + } + if day.GetWorktime(user, models.WorktimeBaseDay, false) >= day.GetWorktime(user, models.WorktimeBaseDay, true) { + continue + } + + worktimeKurzarbeit := day.GetWorktime(user, models.WorktimeBaseDay, true) - day.GetWorktime(user, models.WorktimeBaseDay, false) + + if wDay, ok := day.(*models.WorkDay); !ok || len(wDay.Bookings) == 0 { + continue + } + workday, _ := day.(*models.WorkDay) + + lastBookingTime := workday.Bookings[len(workday.Bookings)-1].Timestamp + kurzarbeitBegin := (*models.Booking).New(nil, user.CardUID, 0, 1, bookingTypeKurzarbeit.Id) + kurzarbeitEnd := (*models.Booking).New(nil, user.CardUID, 0, 2, bookingTypeKurzarbeit.Id) + kurzarbeitBegin.Timestamp = lastBookingTime.Add(time.Minute) + kurzarbeitEnd.Timestamp = lastBookingTime.Add(worktimeKurzarbeit) + + kurzarbeitBegin.InsertWithTimestamp() + kurzarbeitEnd.InsertWithTimestamp() + kurzarbeitAdded += 1 + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(kurzarbeitAdded) +} + +func getKurzarbeitBookingType() (models.BookingType, error) { + for _, bookingType := range models.GetBookingTypesCached() { + if bookingType.Name == "Kurzarbeit" { + return bookingType, nil + } + } + return models.BookingType{}, errors.New("No Booking Type found") +} diff --git a/Backend/endpoints/auto_logout.go b/Backend/endpoints/auto-logout.go similarity index 81% rename from Backend/endpoints/auto_logout.go rename to Backend/endpoints/auto-logout.go index 7d6de92..e15f93a 100644 --- a/Backend/endpoints/auto_logout.go +++ b/Backend/endpoints/auto-logout.go @@ -20,8 +20,8 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) { } func autoLogout(w http.ResponseWriter) { - users, err := (*models.User).GetAll(nil) - var logged_out_users []models.User + users, err := models.GetAllUsers() + var loggedOutUsers []models.User if err != nil { fmt.Printf("Error getting user list %v\n", err) } @@ -31,7 +31,7 @@ func autoLogout(w http.ResponseWriter) { if err != nil { fmt.Printf("Error logging out user %v\n", err) } else { - logged_out_users = append(logged_out_users, user) + loggedOutUsers = append(loggedOutUsers, user) log.Printf("Automaticaly logged out user %s, %s ", user.Name, user.Vorname) } } @@ -39,6 +39,6 @@ func autoLogout(w http.ResponseWriter) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(logged_out_users) + json.NewEncoder(w).Encode(loggedOutUsers) } diff --git a/Backend/endpoints/pdf-create.go b/Backend/endpoints/pdf-create.go new file mode 100644 index 0000000..2557c0f --- /dev/null +++ b/Backend/endpoints/pdf-create.go @@ -0,0 +1,302 @@ +package endpoints + +import ( + "arbeitszeitmessung/helper" + "arbeitszeitmessung/helper/paramParser" + "arbeitszeitmessung/models" + "archive/zip" + "bytes" + "fmt" + "log" + "log/slog" + "net/http" + "time" + + "github.com/Dadido3/go-typst" +) + +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 +} + +func PDFCreateController(w http.ResponseWriter, r *http.Request) { + helper.RequiresLogin(Session, w, r) + switch r.Method { + case http.MethodGet: + user, err := models.GetUserFromSession(Session, r.Context()) + if err != nil { + log.Println("Error getting user!") + return + } + pp := paramParser.New(r.URL.Query()) + startDate := pp.ParseTimestampFallback("start_date", time.DateOnly, time.Now()) + personalNumbers := pp.ParseIntListFallback("employe_list", ",", make([]int, 0)) + + employes, err := models.GetUserByPersonalNrMulti(personalNumbers) + if err != nil { + slog.Warn("Error getting employes!", slog.Any("Error", err)) + return + } + + n := 0 + for _, e := range employes { + if user.IsSuperior(e) { + employes[n] = e + n++ + } + } + employes = employes[:n] + + reportData := createReports(employes, startDate) + + switch pp.ParseStringFallback("output", "render") { + case "render": + output, err := renderPDFSingle(reportData) + if err != nil { + 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))) + output.WriteTo(w) + w.WriteHeader(http.StatusOK) + case "download": + pdfReports, err := renderPDFMulti(reportData) + if err != nil { + slog.Warn("Could not create pdf report", slog.Any("Error", err)) + w.WriteHeader(http.StatusInternalServerError) + } + output, err := zipPfd(pdfReports, &reportData) + if err != nil { + slog.Warn("Could not create pdf report", 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))) + output.WriteTo(w) + w.WriteHeader(http.StatusOK) + } + + default: + http.Error(w, "Method not allowed!", http.StatusMethodNotAllowed) + } +} + +func createReports(employes []models.User, startDate time.Time) []typstData { + startDate = helper.GetFirstOfMonth(startDate) + endDate := startDate.AddDate(0, 1, -1) + + var employeData []typstData + for _, employee := range employes { + if data, err := createEmployeReport(employee, startDate, endDate); err != nil { + slog.Warn("Error when creating employeReport", slog.Any("user", employee), slog.Any("error", err)) + } else { + employeData = append(employeData, data) + } + } + return employeData +} + +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()) + + 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) + if err != nil { + slog.Warn("Failed to convert to days", slog.Any("error", err)) + return typstData{}, 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), + WorkTime: helper.FormatDurationFill(workHours, true), + Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true), + OvertimeTotal: "", + 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 +} + +func renderPDFSingle(data []typstData) (bytes.Buffer, error) { + var markup bytes.Buffer + var output bytes.Buffer + + typstCLI := typst.DockerExec{ + ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"), + } + + if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil { + return output, err + } + + // Import the template and invoke the template function with the custom data. + // Show is used to replace the current document with whatever content the template function in `template.typ` returns. + markup.WriteString(` + #import "templates/abrechnung.typ": abrechnung + #for d in data { + abrechnung(d.Meta, d.Days) + } + `) + + // Compile the prepared markup with Typst and write the result it into `output.pdf`. + if err := typstCLI.Compile(&markup, &output, nil); err != nil { + return output, err + } + return output, nil +} + +func renderPDFMulti(data []typstData) ([]bytes.Buffer, error) { + var outputMulti []bytes.Buffer + + typstRender := typst.DockerExec{ + ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"), + } + + for _, d := range data { + var markup bytes.Buffer + var outputSingle bytes.Buffer + if err := typst.InjectValues(&markup, map[string]any{"meta": d.Meta, "days": d.Days}); err != nil { + return outputMulti, err + } + markup.WriteString(` + #import "templates/abrechnung.typ": abrechnung + #abrechnung(meta, days) + `) + + if err := typstRender.Compile(&markup, &outputSingle, nil); err != nil { + return outputMulti, err + } + outputMulti = append(outputMulti, outputSingle) + } + return outputMulti, nil +} + +func zipPfd(pdfReports []bytes.Buffer, reportData *[]typstData) (bytes.Buffer, error) { + var zipOutput bytes.Buffer + + zipWriter := zip.NewWriter(&zipOutput) + for index, report := range pdfReports { + zipFile, err := zipWriter.Create((*reportData)[index].FileName) + if err != nil { + fmt.Println(err) + } + _, err = zipFile.Write(report.Bytes()) + if err != nil { + fmt.Println(err) + } + } + + // Make sure to check the error on Close. + err := zipWriter.Close() + return zipOutput, err +} + +type typstMetadata struct { + TimeRange string `json:"time-range"` + EmployeeName string `json:"employee-name"` + WorkTime string `json:"worktime"` + Kurzarbeit string `json:"kurzarbeit"` + Overtime string `json:"overtime"` + OvertimeTotal string `json:"overtime-total"` + CurrentTimestamp string `json:"current-timestamp"` +} + +type typstDayPart struct { + BookingFrom string `json:"booking-from"` + BookingTo string `json:"booking-to"` + WorkType string `json:"worktype"` + IsWorkDay bool `json:"is-workday"` +} + +type typstDay struct { + Date string `json:"date"` + DayParts []typstDayPart `json:"day-parts"` + Worktime string `json:"worktime"` + Pausetime string `json:"pausetime"` + Overtime string `json:"overtime"` + Kurzarbeit string `json:"kurzarbeit"` + IsFriday bool `json:"is-weekend"` +} + +type typstData struct { + Meta typstMetadata `json:"meta"` + Days []typstDay `json:"days"` + FileName string +} diff --git a/Backend/endpoints/pdf.go b/Backend/endpoints/pdf.go index 87beb06..e08ef6d 100644 --- a/Backend/endpoints/pdf.go +++ b/Backend/endpoints/pdf.go @@ -4,38 +4,22 @@ import ( "arbeitszeitmessung/helper" "arbeitszeitmessung/models" "arbeitszeitmessung/templates" - "log" + "log/slog" "net/http" - "time" ) -func PDFHandler(w http.ResponseWriter, r *http.Request) { +func PDFFormHandler(w http.ResponseWriter, r *http.Request) { helper.RequiresLogin(Session, w, r) - startDate, err := parseTimestamp(r, "start", time.Now().Format("2006-01-02")) - if err != nil { - log.Println("Error parsing 'start_date' time", err) - http.Error(w, "Timestamp 'start_date' cannot be parsed!", http.StatusBadRequest) - return - } - if startDate.Day() > 1 { - startDate = startDate.AddDate(0, 0, -(startDate.Day() - 1)) - } - endDate := startDate.AddDate(0, 1, -1) user, err := models.GetUserFromSession(Session, r.Context()) if err != nil { - log.Println("Error getting user!") + slog.Warn("Error getting user!", slog.Any("Error", err)) + // TODO add error handling } - //TODO: only accepted weeks - - weeks := models.GetDays(user, startDate, endDate, false) - var aggregatedOvertime, aggregatedWorkTime time.Duration - for _, day := range weeks { - aggregatedOvertime += day.TimeOvertimeReal(user) - aggregatedWorkTime += day.TimeWorkVirtual(user) + teamMembers, err := user.GetTeamMembers() + if err != nil { + slog.Warn("Error getting team members!", slog.Any("Error", err)) } - - // log.Printf("Using Dates: %s - %s\n", startDate.String(), endDate.String()) - templates.PDFReportEmploye(user, aggregatedOvertime, aggregatedWorkTime, weeks, startDate, endDate).Render(r.Context(), w) + templates.PDFForm(teamMembers).Render(r.Context(), w) } diff --git a/Backend/endpoints/team_presence.go b/Backend/endpoints/team-presence.go similarity index 100% rename from Backend/endpoints/team_presence.go rename to Backend/endpoints/team-presence.go diff --git a/Backend/endpoints/team.go b/Backend/endpoints/team.go index 397d45a..efc1c7b 100644 --- a/Backend/endpoints/team.go +++ b/Backend/endpoints/team.go @@ -2,13 +2,13 @@ package endpoints import ( "arbeitszeitmessung/helper" + "arbeitszeitmessung/helper/paramParser" "arbeitszeitmessung/models" "arbeitszeitmessung/templates" "context" "errors" "log" "net/http" - "strconv" "time" ) @@ -30,17 +30,17 @@ func submitReport(w http.ResponseWriter, r *http.Request) { log.Println("Error parsing form", err) return } - userPN, _ := strconv.Atoi(r.FormValue("user")) - _weekTs := r.FormValue("week") - weekTs, err := time.Parse(time.DateOnly, _weekTs) + pp := paramParser.New(r.Form) + userPN, err := pp.ParseInt("user") + weekTs := pp.ParseTimestampFallback("week", time.DateOnly, time.Now()) user, err := models.GetUserByPersonalNr(userPN) - workWeek := models.NewWorkWeek(user, weekTs, true) - if err != nil { log.Println("Could not get user!") return } + workWeek := models.NewWorkWeek(user, weekTs, true) + switch r.FormValue("method") { case "send": err = workWeek.SendWeek() @@ -62,14 +62,11 @@ func showWeeks(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } - submissionDate := r.URL.Query().Get("submission_date") - lastSub := user.GetLastWorkWeekSubmission() - if submissionDate != "" { - submissionDate, err := time.Parse("2006-01-02", submissionDate) - if err == nil { - lastSub = helper.GetMonday(submissionDate) - } - } + + pp := paramParser.New(r.URL.Query()) + submissionDate := pp.ParseTimestampFallback("submission_date", time.DateOnly, user.GetLastWorkWeekSubmission()) + lastSub := helper.GetMonday(submissionDate) + userWeek := models.NewWorkWeek(user, lastSub, true) var workWeeks []models.WorkWeek diff --git a/Backend/endpoints/time.go b/Backend/endpoints/time.go index 102c3f2..1f7985e 100644 --- a/Backend/endpoints/time.go +++ b/Backend/endpoints/time.go @@ -2,14 +2,15 @@ package endpoints import ( "arbeitszeitmessung/helper" + "arbeitszeitmessung/helper/paramParser" "arbeitszeitmessung/models" "arbeitszeitmessung/templates" "context" "database/sql" "encoding/json" "log" + "log/slog" "net/http" - "sort" "strconv" "time" ) @@ -36,8 +37,18 @@ func AbsencHandler(w http.ResponseWriter, r *http.Request) { helper.SetCors(w) switch r.Method { case http.MethodPost: - err := updateAbsence(r) + r.ParseForm() + var err error + switch r.FormValue("action") { + case "insert": + err = updateAbsence(r) + case "delete": + err = deleteAbsence(r) + default: + slog.Warn("No action found!") + } if err != nil { + slog.Warn("Error handling absence route ", "error", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } @@ -52,7 +63,7 @@ func parseTimestamp(r *http.Request, getKey string, fallback string) (time.Time, if getTimestamp == "" { getTimestamp = fallback } - Timestamp, err := time.Parse("2006-01-02", getTimestamp) + Timestamp, err := time.Parse(time.DateOnly, getTimestamp) if err != nil { return time.Now(), err } @@ -67,26 +78,15 @@ func getBookings(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } + pp := paramParser.New(r.URL.Query()) // TODO add config for timeoffset - tsFrom, err := parseTimestamp(r, "time_from", time.Now().AddDate(0, -1, 0).Format("2006-01-02")) - if err != nil { - log.Println("Error parsing 'from' time", err) - http.Error(w, "Timestamp 'from' cannot be parsed!", http.StatusBadRequest) - return - } - tsTo, err := parseTimestamp(r, "time_to", time.Now().Format("2006-01-02")) - if err != nil { - log.Println("Error parsing 'to' time", err) - http.Error(w, "Timestamp 'to' cannot be parsed!", http.StatusBadRequest) - return - } + tsFrom := pp.ParseTimestampFallback("time_from", time.DateOnly, time.Now().AddDate(0, -1, 0)) + tsTo := pp.ParseTimestampFallback("time_to", time.DateOnly, time.Now()) + tsTo = tsTo.AddDate(0, 0, 1) // so that today is inside days := models.GetDays(user, tsFrom, tsTo, true) - sort.Slice(days, func(i, j int) bool { - return days[i].Date().After(days[j].Date()) - }) lastSub := user.GetLastWorkWeekSubmission() var aggregatedOvertime time.Duration @@ -94,7 +94,7 @@ func getBookings(w http.ResponseWriter, r *http.Request) { if day.Date().Before(lastSub) { continue } - aggregatedOvertime += day.TimeOvertimeReal(user) + aggregatedOvertime += day.GetOvertime(user, models.WorktimeBaseDay, false) } if reportedOvertime, err := user.GetReportedOvertime(); err == nil { user.Overtime = (reportedOvertime + aggregatedOvertime).Round(time.Minute) @@ -114,8 +114,24 @@ func getBookings(w http.ResponseWriter, r *http.Request) { templates.TimePage([]models.WorkDay{}, lastSub).Render(ctx, w) } +func deleteAbsence(r *http.Request) error { + r.ParseForm() + pp := paramParser.New(r.Form) + counterId, err := pp.ParseInt("aw_id") + + if err != nil { + return err + } + absence, err := models.GetAbsenceById(counterId) + if err != nil { + return err + } + return absence.Delete() +} + func updateBooking(w http.ResponseWriter, r *http.Request) { r.ParseForm() + pp := paramParser.New(r.Form) var loc *time.Location loc, err := time.LoadLocation(helper.GetEnv("TZ", "Europe/Berlin")) if err != nil { @@ -136,10 +152,9 @@ func updateBooking(w http.ResponseWriter, r *http.Request) { return } - var check_in_out int - check_in_out, err = strconv.Atoi(r.FormValue("check_in_out")) + check_in_out, err := pp.ParseInt("check_in_out") if err != nil { - log.Println("Error parsing check_in_out", err) + slog.Warn("Error parsing check_in_out") return } @@ -193,13 +208,13 @@ func updateAbsence(r *http.Request) error { loc = time.Local } - dateFrom, err := time.ParseInLocation("2006-01-02", r.FormValue("date_from"), loc) + dateFrom, err := time.ParseInLocation(time.DateOnly, r.FormValue("date_from"), loc) if err != nil { log.Println("Error parsing date_from input for absence", err) return err } - dateTo, err := time.ParseInLocation("2006-01-02", r.FormValue("date_to"), loc) + dateTo, err := time.ParseInLocation(time.DateOnly, r.FormValue("date_to"), loc) if err != nil { log.Println("Error parsing date_to input for absence", err) return err @@ -261,22 +276,3 @@ func updateAbsence(r *http.Request) error { return nil } - -func createAbsence(absenceType int, user models.User, loc *time.Location, r *http.Request) { - absenceDate, err := time.ParseInLocation("2006-01-02", r.FormValue("date"), loc) - if err != nil { - log.Println("Cannot get date from input! Skipping absence creation", err) - return - } - - absence, err := models.NewAbsence(user.CardUID, absenceType, absenceDate) - if err != nil { - log.Println("Error creating absence!", err) - return - } - err = absence.Insert() - if err != nil { - log.Println("Error inserting absence!", err) - return - } -} diff --git a/Backend/endpoints/user-settings.go b/Backend/endpoints/user-settings.go index 66f7d1b..65b7ebd 100644 --- a/Backend/endpoints/user-settings.go +++ b/Backend/endpoints/user-settings.go @@ -43,5 +43,5 @@ func showUserPage(w http.ResponseWriter, r *http.Request, status int) { if user, err := models.GetUserFromSession(Session, r.Context()); err == nil { ctx = context.WithValue(r.Context(), "user", user) } - templates.UserPage(status).Render(ctx, w) + templates.SettingsPage(status).Render(ctx, w) } diff --git a/Backend/go.mod b/Backend/go.mod index b993a01..3c70ca4 100644 --- a/Backend/go.mod +++ b/Backend/go.mod @@ -4,11 +4,14 @@ go 1.24.7 require github.com/lib/pq v1.10.9 -require github.com/a-h/templ v0.3.943 +require github.com/a-h/templ v0.3.960 require github.com/alexedwards/scs/v2 v2.8.0 +require github.com/wlbr/feiertage v1.17.0 + require ( + github.com/Dadido3/go-typst v0.8.0 github.com/golang-migrate/migrate/v4 v4.18.3 github.com/joho/godotenv v1.5.1 ) @@ -16,6 +19,14 @@ require ( require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/smasher164/xid v0.1.2 // indirect go.uber.org/atomic v1.7.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.38.0 // indirect ) + +tool golang.org/x/tools/cmd/deadcode diff --git a/Backend/go.sum b/Backend/go.sum index 6c7522d..0c9f849 100644 --- a/Backend/go.sum +++ b/Backend/go.sum @@ -1,11 +1,19 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Dadido3/go-typst v0.8.0 h1:uTLYprhkrBjwsCXRRuyYUFL0fpYHa2kIYoOB/CGqVNs= +github.com/Dadido3/go-typst v0.8.0/go.mod h1:QYis9sT70u65kn1SkFfyPRmHsPxgoxWbAixwfPReOZA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY= github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= +github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +46,7 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -54,10 +63,25 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/smasher164/xid v0.1.2 h1:erplXSdBRIIw+MrwjJ/m8sLN2XY16UGzpTA0E2Ru6HA= +github.com/smasher164/xid v0.1.2/go.mod h1:tgivm8CQl19fH1c5y+8F4mA+qY6n2i6qDRBlY/6nm+I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/wlbr/feiertage v1.10.0/go.mod h1:wJOHvMa6sI5L1FkrTOX/GSoO0hpK3S2YqGLPi8Q84I0= +github.com/wlbr/feiertage v1.17.0 h1:AEck/iUQu19iU0xNEoSQTeSTGXF1Ju0tbAwEi/Lmwqk= +github.com/wlbr/feiertage v1.17.0/go.mod h1:TVZgmSZgGW/jSxexZ56qdlR6cDj+F/FO8bkw8U6kYxM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= @@ -68,7 +92,65 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3 go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/Backend/helper/logs/main.go b/Backend/helper/logs/main.go index 764a51c..9cad2bb 100644 --- a/Backend/helper/logs/main.go +++ b/Backend/helper/logs/main.go @@ -14,7 +14,7 @@ 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("2006-01-02") + ".log" + 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) if err != nil { log.Panic(err) diff --git a/Backend/helper/paramParser/main.go b/Backend/helper/paramParser/main.go new file mode 100644 index 0000000..8dd6586 --- /dev/null +++ b/Backend/helper/paramParser/main.go @@ -0,0 +1,117 @@ +package paramParser + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + "time" +) + +type ParamsParser struct { + urlParams url.Values +} + +func (p ParamsParser) ParseStringListFallback(key string, delimiter string, fallback []string) []string { + if !p.urlParams.Has(key) { + return fallback + } + paramList := p.urlParams.Get(key) + list := strings.Split(paramList, delimiter) + return list +} + +func (p ParamsParser) ParseIntListFallback(key string, delimiter string, fallback []int) []int { + if !p.urlParams.Has(key) { + return fallback + } + paramList := p.urlParams[key] + parsedList := make([]int, 0) + for _, item := range paramList { + if parsedItem, err := strconv.Atoi(item); err == nil { + parsedList = append(parsedList, parsedItem) + } + } + return parsedList +} + +type NoValueError struct { + Key string +} + +func (e *NoValueError) Error() string { + return fmt.Sprintf("No value found for key %s", e.Key) +} + +func New(params url.Values) ParamsParser { + return ParamsParser{ + urlParams: params, + } +} + +func (p *ParamsParser) ParseTimestampFallback(key string, format string, fallback time.Time) time.Time { + if !p.urlParams.Has(key) { + return fallback + } + paramTimestamp := p.urlParams.Get(key) + if timestamp, err := time.Parse(format, paramTimestamp); err == nil { + return timestamp + } else { + slog.Warn("Error parsing HTTP Params to time.Time", slog.Any("key", key), slog.Any("error", err)) + return fallback + } +} + +func (p *ParamsParser) ParseTimestamp(key string, format string) (time.Time, error) { + if !p.urlParams.Has(key) { + return time.Time{}, &NoValueError{Key: key} + } + paramTimestamp := p.urlParams.Get(key) + if timestamp, err := time.Parse(format, paramTimestamp); err == nil { + return timestamp, nil + } else { + slog.Debug("Error parsing HTTP Params to time.Time", slog.Any("key", key), slog.Any("error", err)) + return timestamp, err + } +} + +func (p *ParamsParser) ParseStringFallback(key string, fallback string) string { + if !p.urlParams.Has(key) { + return fallback + } + return p.urlParams.Get(key) +} + +func (p *ParamsParser) ParseString(key string) (string, error) { + if !p.urlParams.Has(key) { + return "", &NoValueError{Key: key} + } + return p.urlParams.Get(key), nil +} + +func (p *ParamsParser) ParseIntFallback(key string, fallback int) int { + if !p.urlParams.Has(key) { + return fallback + } + paramInt := p.urlParams.Get(key) + if result, err := strconv.Atoi(paramInt); err == nil { + return result + } else { + slog.Warn("Error parsing HTTP Params to Int", slog.Any("key", key), slog.Any("error", err)) + return fallback + } +} + +func (p *ParamsParser) ParseInt(key string) (int, error) { + if !p.urlParams.Has(key) { + return 0, &NoValueError{Key: key} + } + paramInt := p.urlParams.Get(key) + if result, err := strconv.Atoi(paramInt); err == nil { + return result, nil + } else { + slog.Debug("Error parsing HTTP Params to Int", slog.Any("key", key), slog.Any("error", err)) + return 0, err + } +} diff --git a/Backend/helper/strings_test.go b/Backend/helper/strings_test.go new file mode 100644 index 0000000..ac1b4f3 --- /dev/null +++ b/Backend/helper/strings_test.go @@ -0,0 +1,47 @@ +package helper + +import "testing" + +func TestGetFirst(t *testing.T) { + tests := []struct { + name string + a any + b any + want any + }{ + {"ints", 10, 20, 10}, + {"strings", "first", "second", "first"}, + {"mixed", "abc", 123, "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetFirst(tt.a, tt.b) + if got != tt.want { + t.Errorf("GetFirst(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestGetSecond(t *testing.T) { + tests := []struct { + name string + a any + b any + want any + }{ + {"ints", 10, 20, 20}, + {"strings", "first", "second", "second"}, + {"mixed", "abc", 123, 123}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetSecond(tt.a, tt.b) + if got != tt.want { + t.Errorf("GetSecond(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/Backend/helper/time.go b/Backend/helper/time.go index 046ad64..1337485 100644 --- a/Backend/helper/time.go +++ b/Backend/helper/time.go @@ -16,6 +16,13 @@ func GetMonday(ts time.Time) time.Time { return ts } +func GetFirstOfMonth(ts time.Time) time.Time { + if ts.Day() > 1 { + return ts.AddDate(0, 0, -(ts.Day() - 1)) + } + return ts +} + func IsWeekend(ts time.Time) bool { return ts.Weekday() == time.Saturday || ts.Weekday() == time.Sunday } @@ -55,3 +62,23 @@ func FormatDurationFill(d time.Duration, fill bool) string { func IsSameDate(a, b time.Time) bool { return a.Truncate(24 * time.Hour).Equal(b.Truncate(24 * time.Hour)) } + +func GetWorkingDays(startDate, endDate time.Time) int { + if endDate.Before(startDate) { + return 0 + } + var count int = 0 + for d := startDate.Truncate(24 * time.Hour); !d.After(endDate); d = d.Add(24 * time.Hour) { + if !IsWeekend(d) { + count++ + } + } + return count +} + +func FormatGermanDayOfWeek(t time.Time) string { + return days[t.Weekday()][:2] +} + +var days = [...]string{ + "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"} diff --git a/Backend/helper/time_test.go b/Backend/helper/time_test.go index 454d4e3..4e6e67e 100644 --- a/Backend/helper/time_test.go +++ b/Backend/helper/time_test.go @@ -1,37 +1,172 @@ package helper import ( + "fmt" "testing" "time" ) func TestGetMonday(t *testing.T) { - isMonday, err := time.Parse("2006-01-02", "2025-07-14") - notMonday, err := time.Parse("2006-01-02", "2025-07-16") + isMonday, err := time.Parse(time.DateOnly, "2025-07-14") + isSunday, err := time.Parse(time.DateOnly, "2025-07-20") + notMonday, err := time.Parse(time.DateOnly, "2025-07-16") if err != nil || isMonday.Equal(notMonday) { t.Errorf("U stupid? %e", err) } - if GetMonday(isMonday) != isMonday || GetMonday(notMonday) != isMonday { + if GetMonday(isMonday) != isMonday { t.Error("Wrong date conversion!") } + + if GetMonday(notMonday) != isMonday { + t.Error("Wrong date conversion (notMonday)!") + } + + if GetMonday(isSunday) != isMonday { + t.Error("Wrong date conversion (isSunday)!") + } } -func TestFormatDuration(t *testing.T) { - durations := []struct { +func TestFormatDurationFill(t *testing.T) { + testCases := []struct { name string duration time.Duration + fill bool }{ - {"2h", time.Duration(120 * time.Minute)}, - {"30min", time.Duration(30 * time.Minute)}, - {"1h 30min", time.Duration(90 * time.Minute)}, - {"-1h 30min", time.Duration(-90 * time.Minute)}, - {"", 0}, + {"2h", time.Duration(120 * time.Minute), true}, + {"30min", time.Duration(30 * time.Minute), true}, + {"1h 30min", time.Duration(90 * time.Minute), true}, + {"-1h 30min", time.Duration(-90 * time.Minute), true}, + {"0min", 0, true}, + {"", 0, false}, } - for _, d := range durations { - t.Run(d.name, func(t *testing.T) { - if FormatDuration(d.duration) != d.name { + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if FormatDurationFill(tc.duration, tc.fill) != tc.name { t.Error("Format missmatch in Formatduration.") } }) } } + +func TestFormatDuration(t *testing.T) { + testCases := []struct { + name string + duration time.Duration + }{ + {"", 0}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if FormatDuration(tc.duration) != tc.name { + t.Error("Format missmatch in Formatduration.") + } + }) + } +} + +func TestIsSameDate(t *testing.T) { + testCases := []struct { + dateA string + dateB string + result bool + }{ + {"2025-12-01 00:00:00", "2025-12-01 00:00:00", true}, + {"2025-12-03 00:00:00", "2025-12-02 00:00:00", false}, + {"2025-12-03 23:45:00", "2025-12-03 00:00:00", true}, + {"2025-12-04 24:12:00", "2025-12-04 00:12:00", false}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("IsSameDateTest: %s date", tc.dateA), func(t *testing.T) { + dateA, _ := time.Parse(time.DateTime, tc.dateA) + dateB, _ := time.Parse(time.DateTime, tc.dateB) + if IsSameDate(dateA, dateB) != tc.result { + t.Errorf("Is SameDate did not match! Result %t", IsSameDate(dateA, dateB)) + } + }) + } +} + +func TestGetWorkingDays(t *testing.T) { + testCases := []struct { + start string + end string + days int + }{ + {"2025-10-01", "2025-10-02", 2}, + {"2025-10-02", "2025-10-01", 0}, + {"2025-10-01", "2025-10-31", 23}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("WorkingDayTest: %d days", tc.days), func(t *testing.T) { + startDate, _ := time.Parse(time.DateOnly, tc.start) + endDate, _ := time.Parse(time.DateOnly, tc.end) + if GetWorkingDays(startDate, endDate) != tc.days { + t.Error("Calculated workdays do not match target") + } + }) + } +} + +func TestFormatGermanDayOfWeek(t *testing.T) { + testCases := []struct { + date string + result string + }{ + {"2025-12-01", "Mo"}, + {"2025-12-02", "Di"}, + {"2025-12-03", "Mi"}, + {"2025-12-04", "Do"}, + {"2025-12-05", "Fr"}, + {"2025-12-06", "Sa"}, + {"2025-12-07", "So"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("FormatWeekDayTest: %s date", tc.date), func(t *testing.T) { + date, _ := time.Parse(time.DateOnly, tc.date) + if FormatGermanDayOfWeek(date) != tc.result { + t.Error("Formatted workday did not match!") + } + }) + } +} + +func TestGetKW(t *testing.T) { + tests := []struct { + name string + date time.Time + want int + }{ + { + name: "First week of year", + date: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), // Monday + want: 1, + }, + { + name: "Middle of year", + date: time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC), + want: 24, + }, + { + name: "Last week of year", + date: time.Date(2023, 12, 31, 0, 0, 0, 0, time.UTC), + want: 52, + }, + { + name: "ISO week crossing into next year", + date: time.Date(2020, 12, 31, 0, 0, 0, 0, time.UTC), + want: 53, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetKW(tt.date) + if got != tt.want { + t.Errorf("GetKW(%v) = %d, want %d", tt.date, got, tt.want) + } + }) + } +} diff --git a/Backend/helper/types_test.go b/Backend/helper/types_test.go new file mode 100644 index 0000000..deb9821 --- /dev/null +++ b/Backend/helper/types_test.go @@ -0,0 +1,26 @@ +package helper + +import ( + "fmt" + "testing" +) + +func TestBoolToInt(t *testing.T) { + testCases := []struct { + value bool + res int + res8 int8 + }{ + {true, 1, 1}, + {false, 0, 0}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("BoolToInt value: %t", tc.value), func(t *testing.T) { + if BoolToInt(tc.value) != tc.res || BoolToInt8(tc.value) != tc.res8 { + t.Error("How could you... mess up bool to int") + } + }) + } + +} diff --git a/Backend/helper/web_test.go b/Backend/helper/web_test.go new file mode 100644 index 0000000..5d39886 --- /dev/null +++ b/Backend/helper/web_test.go @@ -0,0 +1,112 @@ +package helper + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/alexedwards/scs/v2" +) + +func TestSetCors_WhenNoCorsTrue(t *testing.T) { + os.Setenv("NO_CORS", "true") + defer os.Unsetenv("NO_CORS") + + rr := httptest.NewRecorder() + + SetCors(rr) + + h := rr.Header() + + if h.Get("Access-Control-Allow-Origin") != "*" { + t.Errorf("expected Access-Control-Allow-Origin to be '*', got %q", h.Get("Access-Control-Allow-Origin")) + } + + if h.Get("Access-Control-Allow-Methods") != "*" { + t.Errorf("expected Access-Control-Allow-Methods to be '*', got %q", h.Get("Access-Control-Allow-Methods")) + } + + if h.Get("Access-Control-Allow-Headers") != "*" { + t.Errorf("expected Access-Control-Allow-Headers to be '*', got %q", h.Get("Access-Control-Allow-Headers")) + } +} + +func TestSetCors_WhenNoCorsFalse(t *testing.T) { + os.Setenv("NO_CORS", "false") + defer os.Unsetenv("NO_CORS") + + rr := httptest.NewRecorder() + + SetCors(rr) + + h := rr.Header() + if h.Get("Access-Control-Allow-Origin") != "" || + h.Get("Access-Control-Allow-Methods") != "" || + h.Get("Access-Control-Allow-Headers") != "" { + t.Errorf("CORS headers should not be set when NO_CORS=false") + } +} + +func TestRequiresLogin_DebugMode_NoRedirect(t *testing.T) { + os.Setenv("GO_ENV", "debug") + defer os.Unsetenv("GO_ENV") + + session := scs.New() + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + RequiresLogin(session, rr, req) + + if rr.Result().StatusCode == http.StatusSeeOther { + t.Errorf("expected no redirect in debug mode") + } +} + +// func TestRequiresLogin_UserExists_NoRedirect(t *testing.T) { +// os.Setenv("GO_ENV", "production") +// defer os.Unsetenv("GO_ENV") + +// session := scs.New() + +// req := httptest.NewRequest("GET", "/", nil) +// ctx, err := session.Load(req.Context(), "") +// if err != nil { +// t.Fatalf("session load error: %v", err) +// } + +// ctx = session.Put(ctx, "user", "123") +// req = req.WithContext(context.WithValue(ctx, "session", session)) + +// rr := httptest.NewRecorder() + +// yourpkg.RequiresLogin(session, rr, req) + +// if rr.Result().StatusCode == http.StatusSeeOther { +// t.Errorf("expected no redirect when user exists") +// } +// } + +// func TestRequiresLogin_NoUser_Redirects(t *testing.T) { +// os.Setenv("GO_ENV", "production") +// defer os.Unsetenv("GO_ENV") + +// session := scs.New() + +// req := httptest.NewRequest("GET", "/", nil) +// req = req.WithContext(context.WithValue(req.Context(), "session", session)) + +// rr := httptest.NewRecorder() + +// RequiresLogin(session, rr, req) + +// if rr.Result().StatusCode != http.StatusSeeOther { +// t.Errorf("expected redirect when user does not exist, got %d", rr.Result().StatusCode) +// } + +// location := rr.Result().Header.Get("Location") +// if location != "/user/login" { +// t.Errorf("expected redirect to /user/login, got %q", location) +// } +// } diff --git a/Backend/main.go b/Backend/main.go index 35395e2..4832dc0 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -5,8 +5,7 @@ import ( "arbeitszeitmessung/helper" "arbeitszeitmessung/models" "context" - "fmt" - "log" + "log/slog" "net/http" "os" "time" @@ -17,23 +16,25 @@ import ( func main() { var err error + var logLevel slog.LevelVar + logLevel.Set(slog.LevelWarn) + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &logLevel})) + slog.SetDefault(logger) err = godotenv.Load(".env") if err != nil { - log.Println("No .env file found in directory!") + slog.Info("No .env file found in directory!") } if helper.GetEnv("GO_ENV", "production") == "debug" { - log.Println("Debug mode enabled") - log.Println("Environment Variables") + logLevel.Set(slog.LevelDebug) envs := os.Environ() - for _, e := range envs { - fmt.Println(e) - } + slog.Debug("Debug mode enabled", "Environment Variables", envs) } models.DB, err = OpenDatabase() if err != nil { - log.Fatal(err) + slog.Error("Error while opening the database", "Error", err) } fs := http.FileServer(http.Dir("./static")) @@ -45,27 +46,31 @@ func main() { server.HandleFunc("/time/new", endpoints.TimeCreateHandler) server.Handle("/absence", ParamsMiddleware(endpoints.AbsencHandler)) server.Handle("/time", ParamsMiddleware(endpoints.TimeHandler)) - server.HandleFunc("/logout", endpoints.LogoutHandler) + server.HandleFunc("/auto/logout", endpoints.LogoutHandler) + server.HandleFunc("/auto/kurzarbeit", endpoints.KurzarbeitFillHandler) + server.HandleFunc("/auto/feiertage", endpoints.FeiertagsHandler) server.HandleFunc("/user/{action}", endpoints.UserHandler) // server.HandleFunc("/user/login", endpoints.LoginHandler) // server.HandleFunc("/user/settings", endpoints.UserSettingsHandler) server.HandleFunc("/team", endpoints.TeamHandler) - server.HandleFunc("/team/presence", endpoints.TeamPresenceHandler) - server.HandleFunc("/pdf", endpoints.PDFHandler) + server.HandleFunc("/presence", endpoints.TeamPresenceHandler) + server.Handle("/pdf", ParamsMiddleware(endpoints.PDFFormHandler)) + server.HandleFunc("/pdf/generate", endpoints.PDFCreateController) server.Handle("/", http.RedirectHandler("/time", http.StatusPermanentRedirect)) server.Handle("/static/", http.StripPrefix("/static/", fs)) serverSessionMiddleware := endpoints.Session.LoadAndSave(server) // starting the http server - fmt.Printf("Server is running at http://localhost:%s\n", helper.GetEnv("EXPOSED_PORT", "8080")) - log.Fatal(http.ListenAndServe(":8080", serverSessionMiddleware)) + slog.Info("Server is running at http://localhost:8080") + slog.Error("Error starting Server", "Error", http.ListenAndServe(":8080", serverSessionMiddleware)) } func ParamsMiddleware(next http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { queryParams := r.URL.Query() ctx := context.WithValue(r.Context(), "urlParams", queryParams) + slog.Debug("ParamsMiddleware added urlParams", slog.Any("urlParams", queryParams)) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/Backend/models/absence.go b/Backend/models/absence.go index e3296a4..0f1f03b 100644 --- a/Backend/models/absence.go +++ b/Backend/models/absence.go @@ -22,6 +22,11 @@ type Absence struct { DateTo time.Time } +// IsEmpty implements [IWorkDay]. +func (a *Absence) IsEmpty() bool { + return false +} + func NewAbsence(card_uid string, abwesenheit_typ int, datum time.Time) (Absence, error) { if abwesenheit_typ < 0 { return Absence{ @@ -45,34 +50,57 @@ func (a *Absence) Date() time.Time { return a.Day.Truncate(24 * time.Hour) } +func (a *Absence) Type() DayType { + return DayTypeAbsence +} + func (a *Absence) IsMultiDay() bool { return !a.DateFrom.Equal(a.DateTo) } -func (a *Absence) TimeWorkVirtual(u User) time.Duration { - return a.TimeWorkReal(u) -} - -func (a *Absence) TimeWorkReal(u User) time.Duration { - if a.AbwesenheitTyp.WorkTime > 1 { - return time.Duration(u.ArbeitszeitPerTag * float32(time.Hour)).Round(time.Minute) +func (a *Absence) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + switch base { + case WorktimeBaseDay: + if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit { + return u.ArbeitszeitProTagFrac(1) + } else if a.AbwesenheitTyp.WorkTime <= 0 { + return 0 + } + return u.ArbeitszeitProTagFrac(float32(a.AbwesenheitTyp.WorkTime) / 100) + case WorktimeBaseWeek: + if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit { + return u.ArbeitszeitProTagFrac(0.2) + } else if a.AbwesenheitTyp.WorkTime <= 0 { + return 0 + } + return u.ArbeitszeitProWocheFrac(0.2 * float32(a.AbwesenheitTyp.WorkTime) / 100) } return 0 } -func (a *Absence) TimePauseReal(u User) (work, pause time.Duration) { - return 0, 0 +func (a *Absence) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + return 0 } -func (a *Absence) TimeOvertimeReal(u User) time.Duration { - if a.AbwesenheitTyp.WorkTime > 1 { +func (a *Absence) GetOvertime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + if a.AbwesenheitTyp.WorkTime > 0 { return 0 } - return -u.ArbeitszeitProTag() + switch base { + case WorktimeBaseDay: + return -u.ArbeitszeitProTagFrac(1) + case WorktimeBaseWeek: + return -u.ArbeitszeitProWocheFrac(0.2) + } + return 0 +} + +func (a *Absence) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (work, pause, overtime time.Duration) { + return a.GetWorktime(u, base, includeKurzarbeit), a.GetPausetime(u, base, includeKurzarbeit), a.GetOvertime(u, base, includeKurzarbeit) } func (a *Absence) ToString() string { - return "Abwesenheit" + return a.AbwesenheitTyp.Name } func (a *Absence) IsWorkDay() bool { @@ -84,7 +112,7 @@ func (a *Absence) IsKurzArbeit() bool { } func (a *Absence) GetDayProgress(u User) int8 { - return 100 + return a.AbwesenheitTyp.WorkTime } func (a *Absence) RequiresAction() bool { @@ -266,3 +294,12 @@ func GetAbsenceTypeById(absenceTypeId int8) (AbsenceType, error) { } return absenceType, nil } + +func (a *Absence) Delete() error { + qStr, err := DB.Prepare("DELETE from abwesenheit WHERE counter_id = $1;") + if err != nil { + return err + } + _, err = qStr.Exec(a.CounterId) + return err +} diff --git a/Backend/models/absence_test.go b/Backend/models/absence_test.go new file mode 100644 index 0000000..235579b --- /dev/null +++ b/Backend/models/absence_test.go @@ -0,0 +1,92 @@ +package models_test + +import ( + "arbeitszeitmessung/helper" + "arbeitszeitmessung/models" + "testing" + "time" +) + +var testAbsence = models.Absence{ + Day: CatchError(time.Parse(time.DateOnly, "2025-01-01")), + AbwesenheitTyp: models.AbsenceType{}, + DateFrom: CatchError(time.Parse(time.DateOnly, "2025-01-01")), + DateTo: CatchError(time.Parse(time.DateOnly, "2025-01-03")), +} + +var testKurzarbeit = models.AbsenceType{ + Name: "Kurzarbeit", + WorkTime: -1, +} + +var testUrlaub = models.AbsenceType{ + Name: "Urlaub", + WorkTime: 100, +} + +var testUrlaubUntertags = models.AbsenceType{ + Name: "Urlaub untertags", + WorkTime: 50, +} + +func TestCalcRealWorkTimeDayAbsence(t *testing.T) { + testCases := []struct { + absenceType models.AbsenceType + expectedTime time.Duration + }{ + { + absenceType: testUrlaub, + expectedTime: time.Hour * 8, + }, + { + absenceType: testUrlaubUntertags, + expectedTime: time.Hour * 4, + }, + { + absenceType: testKurzarbeit, + expectedTime: 0, + }, + } + + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.absenceType.Name, func(t *testing.T) { + var testCase = testAbsence + testCase.AbwesenheitTyp = tc.absenceType + workTime := testCase.GetWorktime(testUser, models.WorktimeBaseDay, false) + if workTime != tc.expectedTime { + t.Errorf("Calc Worktime Default not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) + } + }) + } +} + +func TestCalcRealWorkTimeWeekAbsence(t *testing.T) { + testCases := []struct { + absenceType models.AbsenceType + expectedTime time.Duration + }{ + { + absenceType: testUrlaub, + expectedTime: time.Hour * 7, + }, + { + absenceType: testUrlaubUntertags, + expectedTime: time.Hour*3 + time.Minute*30, + }, + { + absenceType: testKurzarbeit, + expectedTime: 0, + }, + } + + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.absenceType.Name, func(t *testing.T) { + var testCase = testAbsence + testCase.AbwesenheitTyp = tc.absenceType + workTime := testCase.GetWorktime(testUser, models.WorktimeBaseWeek, false) + if workTime != tc.expectedTime { + t.Errorf("Calc Worktime Default not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) + } + }) + } +} diff --git a/Backend/models/booking.go b/Backend/models/booking.go index 7b4d356..28b638a 100644 --- a/Backend/models/booking.go +++ b/Backend/models/booking.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" "log" + "log/slog" "net/url" "strconv" "time" @@ -87,6 +88,27 @@ func (b *Booking) Verify() bool { return true } +func (b *Booking) IsSubmittedAndChecked() bool { + qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(anwesenheiten);`) + if err != nil { + slog.Warn("Error when preparing SQL Statement", "error", err) + return false + } + defer qStr.Close() + var isSubmittedAndChecked bool = false + + err = qStr.QueryRow(b.CounterId).Scan(&isSubmittedAndChecked) + if err == sql.ErrNoRows { + // No rows found ==> not even submitted + return false + } + + if err != nil { + slog.Warn("Unexpected error when executing SQL Statement", "error", err) + } + return isSubmittedAndChecked +} + func (b *Booking) Insert() error { if !checkLastBooking(*b) { return SameBookingError{} diff --git a/Backend/models/booking_test.go b/Backend/models/booking_test.go index 032c59e..c5f3e67 100644 --- a/Backend/models/booking_test.go +++ b/Backend/models/booking_test.go @@ -10,36 +10,36 @@ var testBookingType = models.BookingType{ Name: "Büro", } -var testBookings8hrs = []models.Booking{models.Booking{ +var testBookings8hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), BookingType: testBookingType, -}, models.Booking{ +}, { CardUID: "aaaa-aaaa", CheckInOut: 2, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:00")), BookingType: testBookingType, }} -var testBookings6hrs = []models.Booking{models.Booking{ +var testBookings6hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), BookingType: testBookingType, -}, models.Booking{ +}, { CardUID: "aaaa-aaaa", CheckInOut: 2, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 14:00")), BookingType: testBookingType, }} -var testBookings10hrs = []models.Booking{models.Booking{ +var testBookings10hrs = []models.Booking{{ CardUID: "aaaa-aaaa", CheckInOut: 1, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), BookingType: testBookingType, -}, models.Booking{ +}, { CardUID: "aaaa-aaaa", CheckInOut: 2, Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 18:00")), diff --git a/Backend/models/compoundDay.go b/Backend/models/compoundDay.go new file mode 100644 index 0000000..618d919 --- /dev/null +++ b/Backend/models/compoundDay.go @@ -0,0 +1,105 @@ +package models + +import ( + "log/slog" + "time" +) + +type CompoundDay struct { + Day time.Time + DayParts []IWorkDay +} + +func NewCompondDay(date time.Time, dayParts ...IWorkDay) *CompoundDay { + return &CompoundDay{Day: date, DayParts: dayParts} +} + +func (c *CompoundDay) AddDayPart(dayPart IWorkDay) { + c.DayParts = append(c.DayParts, dayPart) +} + +func (c *CompoundDay) GetWorkDay() WorkDay { + workday, ok := c.DayParts[0].(*WorkDay) + if ok { + return *workday + } + return WorkDay{} +} + +// IsEmpty implements [IWorkDay]. +func (c *CompoundDay) IsEmpty() bool { + return len(c.DayParts) > 0 +} + +// Date implements [IWorkDay]. +func (c *CompoundDay) Date() time.Time { + return c.Day +} + +// GetDayProgress implements [IWorkDay]. +func (c *CompoundDay) GetDayProgress(u User) int8 { + var dayProcess int8 + for _, day := range c.DayParts { + dayProcess += day.GetDayProgress(u) + } + return dayProcess +} + +// GetOvertime implements [IWorkDay]. +func (c *CompoundDay) GetOvertime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + + var overtime time.Duration + for _, day := range c.DayParts { + overtime += day.GetOvertime(u, base, includeKurzarbeit) + } + return overtime +} + +// GetPausetime implements [IWorkDay]. +func (c *CompoundDay) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + var pausetime time.Duration + for _, day := range c.DayParts { + pausetime += day.GetPausetime(u, base, includeKurzarbeit) + } + return pausetime +} + +// GetTimes implements [IWorkDay]. +func (c *CompoundDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (work time.Duration, pause time.Duration, overtime time.Duration) { + return c.GetWorktime(u, base, includeKurzarbeit), c.GetPausetime(u, base, includeKurzarbeit), c.GetOvertime(u, base, includeKurzarbeit) +} + +// GetWorktime implements [IWorkDay]. +func (c *CompoundDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + var worktime time.Duration + for _, day := range c.DayParts { + worktime += day.GetWorktime(u, base, includeKurzarbeit) + slog.Info("Calc worktime for day", "day", day, "worktime", worktime.String()) + } + return worktime +} + +// IsKurzArbeit implements [IWorkDay]. +func (c *CompoundDay) IsKurzArbeit() bool { + return false +} + +// IsWorkDay implements [IWorkDay]. +func (c *CompoundDay) IsWorkDay() bool { + return true +} + +// RequiresAction implements [IWorkDay]. +func (c *CompoundDay) RequiresAction() bool { + return false +} + +// ToString implements [IWorkDay]. +func (c *CompoundDay) ToString() string { + return "Compound Day" +} + +// Type implements [IWorkDay]. +func (c *CompoundDay) Type() DayType { + return DayTypeCompound +} diff --git a/Backend/models/iworkday.go b/Backend/models/iworkday.go new file mode 100644 index 0000000..6305842 --- /dev/null +++ b/Backend/models/iworkday.go @@ -0,0 +1,91 @@ +package models + +import ( + "log/slog" + "time" +) + +type IWorkDay interface { + Date() time.Time + ToString() string + Type() DayType + IsWorkDay() bool + IsKurzArbeit() bool + GetDayProgress(User) int8 + RequiresAction() bool + GetWorktime(User, WorktimeBase, bool) time.Duration + GetPausetime(User, WorktimeBase, bool) time.Duration + GetTimes(User, WorktimeBase, bool) (work, pause, overtime time.Duration) + GetOvertime(User, WorktimeBase, bool) time.Duration + IsEmpty() bool +} + +type DayType int + +const ( + DayTypeWorkday DayType = 1 + DayTypeAbsence DayType = 2 + DayTypeHoliday DayType = 3 + DayTypeCompound DayType = 4 +) + +func GetDays(user User, tsFrom, tsTo time.Time, orderedForward bool) []IWorkDay { + var allDays map[string]IWorkDay = make(map[string]IWorkDay) + + workdays := GetWorkDays(user, tsFrom, tsTo) + absences, err := GetAbsencesByCardUID(user.CardUID, tsFrom, tsTo) + if err != nil { + slog.Warn("Error gettings absences!", slog.Any("Error", err)) + return nil + } + holidays, err := GetHolidaysFromTo(tsFrom, tsTo) + if err != nil { + slog.Warn("Error getting holidays!", slog.Any("Error", err)) + return nil + } + + for _, day := range workdays { + allDays[day.Date().Format(time.DateOnly)] = &day + } + + for _, absentDay := range absences { + // Kurzarbeit should be integrated in workday + existingDay, ok := allDays[absentDay.Date().Format(time.DateOnly)] + if !ok { + allDays[absentDay.Date().Format(time.DateOnly)] = &absentDay + continue + } + switch { + case absentDay.AbwesenheitTyp.WorkTime < 0: + if workDay, ok := allDays[absentDay.Date().Format(time.DateOnly)].(*WorkDay); ok { + workDay.kurzArbeit = true + workDay.kurzArbeitAbsence = absentDay + } + case !existingDay.IsEmpty(): + allDays[absentDay.Date().Format(time.DateOnly)] = NewCompondDay(absentDay.Date(), existingDay, &absentDay) + default: + allDays[absentDay.Date().Format(time.DateOnly)] = &absentDay + } + } + + for _, holiday := range holidays { + existingDay, ok := allDays[holiday.Date().Format(time.DateOnly)] + if !ok { + allDays[holiday.Date().Format(time.DateOnly)] = &holiday + continue + } + slog.Info("Existing Day", "day", existingDay) + switch { + case existingDay.Type() == DayTypeCompound: + allDays[holiday.Date().Format(time.DateOnly)].(*CompoundDay).AddDayPart(&holiday) + case existingDay.Type() != DayTypeCompound && !existingDay.IsEmpty(): + allDays[holiday.Date().Format(time.DateOnly)] = NewCompondDay(holiday.Date(), existingDay, &holiday) + default: + allDays[holiday.Date().Format(time.DateOnly)] = &holiday + } + slog.Debug("Logging Holiday: ", slog.String("HolidayName", allDays[holiday.Date().Format(time.DateOnly)].ToString()), slog.Any("Overtime", holiday.GetOvertime(user, WorktimeBaseDay, false).String()), "wokrtie", float32(holiday.worktime)/100) + } + + sortedDays := sortDays(allDays, orderedForward) + return sortedDays +} diff --git a/Backend/models/publicHoliday.go b/Backend/models/publicHoliday.go new file mode 100644 index 0000000..567a610 --- /dev/null +++ b/Backend/models/publicHoliday.go @@ -0,0 +1,137 @@ +package models + +import ( + "time" + + "github.com/wlbr/feiertage" +) + +// type PublicHoliday feiertage.Feiertag + +type PublicHoliday struct { + feiertage.Feiertag + repeat int8 + worktime int8 +} + +// IsEmpty implements [IWorkDay]. +func (p *PublicHoliday) IsEmpty() bool { + return false +} + +func NewHolidayFromFeiertag(f feiertage.Feiertag) PublicHoliday { + return PublicHoliday{ + Feiertag: f, + repeat: 0, + worktime: 100, + } +} + +func GetHolidaysFromTo(tsFrom, tsTo time.Time) ([]PublicHoliday, error) { + var publicHolidays []PublicHoliday + qStr, err := DB.Prepare(`SELECT datum, name, wiederholen, arbeitszeit_equivalent FROM s_feiertage WHERE datum::DATE >= $1::DATE AND datum::DATE <= $2::DATE;`) + if err != nil { + return publicHolidays, err + } + + rows, err := qStr.Query(tsFrom, tsTo) + if err != nil { + return publicHolidays, err + } + defer rows.Close() + for rows.Next() { + var publicHoliday PublicHoliday + if err := rows.Scan(&publicHoliday.Time, &publicHoliday.Text, &publicHoliday.repeat, &publicHoliday.worktime); err != nil { + return publicHolidays, err + } + publicHolidays = append(publicHolidays, publicHoliday) + } + return publicHolidays, nil +} + +func GetRepeatingHolidays(tsFrom, tsTo time.Time) ([]PublicHoliday, error) { + var publicHolidays []PublicHoliday + qStr, err := DB.Prepare(`SELECT datum, name, wiederholen, arbeitszeit_equivalent FROM s_feiertage WHERE wiederholen = 1 AND datum::DATE >= $1::DATE AND datum::DATE <= $2::DATE;`) + if err != nil { + return publicHolidays, err + } + rows, err := qStr.Query(tsFrom, tsTo) + if err != nil { + return publicHolidays, err + } + defer rows.Close() + for rows.Next() { + var publicHoliday PublicHoliday + if err := rows.Scan(&publicHoliday.Time, &publicHoliday.Text, &publicHoliday.repeat, &publicHoliday.worktime); err != nil { + return publicHolidays, err + } + publicHolidays = append(publicHolidays, publicHoliday) + } + return publicHolidays, nil + +} +func (p *PublicHoliday) Insert() error { + qStr, err := DB.Prepare(`INSERT INTO s_feiertage(name, datum, wiederholen, arbeitszeit_equivalent) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING;`) + if err != nil { + return err + } + _, err = qStr.Exec(p.Text, p.Time, p.repeat, p.worktime) + return err +} + +func (p *PublicHoliday) Type() DayType { + return DayTypeHoliday +} + +// Interface implementation +func (p *PublicHoliday) Date() time.Time { + return p.Time +} + +func (p *PublicHoliday) ToString() string { + return p.Text +} + +func (p *PublicHoliday) IsWorkDay() bool { + return false +} + +func (p *PublicHoliday) IsKurzArbeit() bool { + return false +} + +func (p *PublicHoliday) GetDayProgress(User) int8 { + return p.worktime +} + +func (p *PublicHoliday) RequiresAction() bool { + return false +} + +func (p *PublicHoliday) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + switch base { + case WorktimeBaseDay: + return u.ArbeitszeitProTagFrac(float32(p.worktime) / 100) + case WorktimeBaseWeek: + return u.ArbeitszeitProWocheFrac(float32(p.worktime) / 500) + } + return 0 +} + +func (p *PublicHoliday) GetPausetime(User, WorktimeBase, bool) time.Duration { + return 0 +} + +func (p *PublicHoliday) GetOvertime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + switch base { + case WorktimeBaseDay: + return u.ArbeitszeitProTagFrac(float32(p.worktime)/100) - u.ArbeitszeitProTagFrac(1) + case WorktimeBaseWeek: + return u.ArbeitszeitProWocheFrac(float32(p.worktime)/500) - u.ArbeitszeitProWocheFrac(0.2) + } + return 0 +} + +func (p *PublicHoliday) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (work, pause, overtime time.Duration) { + return p.GetWorktime(u, base, includeKurzarbeit), 0, 0 +} diff --git a/Backend/models/user.go b/Backend/models/user.go index e3d94a9..5cfb559 100644 --- a/Backend/models/user.go +++ b/Backend/models/user.go @@ -7,9 +7,11 @@ import ( "errors" "fmt" "log" + "log/slog" "time" "github.com/alexedwards/scs/v2" + "github.com/lib/pq" ) type User struct { @@ -57,11 +59,37 @@ func (u *User) GetReportedOvertime() (time.Duration, error) { return overtime, nil } +func GetAllUsers() ([]User, error) { + qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname,arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten;`)) + var users []User + if err != nil { + return users, err + } + defer qStr.Close() + rows, err := qStr.Query() + if err != nil { + return users, err + } + defer rows.Close() + for rows.Next() { + + 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) + } + if err = rows.Err(); err != nil { + return users, nil + } + return users, nil +} + func (u *User) GetAll() ([]User, error) { qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname FROM s_personal_daten;`)) var users []User if err != nil { - fmt.Printf("Error preparing query statement %v\n", err) return users, err } defer qStr.Close() @@ -85,13 +113,21 @@ func (u *User) GetAll() ([]User, error) { return users, nil } -// Returns the worktime per day rounded to minutes func (u *User) ArbeitszeitProTag() time.Duration { - return time.Duration(u.ArbeitszeitPerTag * float32(time.Hour)).Round(time.Minute) + return u.ArbeitszeitProTagFrac(1) +} + +// Returns the worktime per day rounded to minutes +func (u *User) ArbeitszeitProTagFrac(fraction float32) time.Duration { + return time.Duration(u.ArbeitszeitPerTag * float32(time.Hour) * fraction).Round(time.Minute) } func (u *User) ArbeitszeitProWoche() time.Duration { - return time.Duration(u.ArbeitszeitPerWoche * float32(time.Hour)).Round(time.Minute) + return u.ArbeitszeitProWocheFrac(1) +} + +func (u *User) ArbeitszeitProWocheFrac(fraction float32) time.Duration { + return time.Duration(u.ArbeitszeitPerWoche * float32(time.Hour) * fraction).Round(time.Minute) } // Returns true if there is a booking 1 for today -> meaning the user is at work @@ -99,7 +135,7 @@ func (u *User) ArbeitszeitProWoche() time.Duration { func (u *User) CheckAnwesenheit() bool { qStr, err := DB.Prepare((`SELECT check_in_out FROM anwesenheit WHERE card_uid = $1 AND "timestamp"::date = now()::date ORDER BY "timestamp" DESC LIMIT 1;`)) if err != nil { - fmt.Printf("Error preparing query statement %v\n", err) + slog.Debug("Error preparing query statement.", "error", err) return false } defer qStr.Close() @@ -137,11 +173,43 @@ func GetUserByPersonalNr(personalNummer int) (User, error) { return user, nil } +func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) { + var users []User + if len(personalNummerMulti) == 0 { + 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[]);`)) + if err != nil { + return users, err + } + + rows, err := qStr.Query(pq.Array(personalNummerMulti)) + if err == sql.ErrNoRows { + return users, err + } + if err != nil { + return nil, err + } + 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 { + return users, err + } + users = append(users, user) + } + if err = rows.Err(); err != nil { + return users, err + } + return users, nil +} + func (u *User) Login(password string) bool { var loginSuccess bool qStr, err := DB.Prepare((`SELECT (pass_hash = crypt($2, pass_hash)) AS pass_hash FROM user_password WHERE personal_nummer = $1;`)) if err != nil { - log.Println("Error preparing db statement", err) + slog.Debug("Error preparing query statement.", "error", err) return false } defer qStr.Close() @@ -233,7 +301,7 @@ func (u *User) GetLastWorkWeekSubmission() time.Time { ) AS letzte_buchung; `) if err != nil { - log.Println("Error preparing statement!", err) + slog.Debug("Error preparing query statement.", "error", err) return lastSub } err = qStr.QueryRow(u.PersonalNummer, u.CardUID).Scan(&lastSub) @@ -262,6 +330,22 @@ func (u *User) GetFromCardUID(card_uid string) (User, error) { 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`) + if err != nil { + slog.Debug("Error preparing query", "error", err) + return false + } + err = qStr.QueryRow(e.PersonalNummer, u.PersonalNummer).Scan(&isSuperior) + if err != nil { + slog.Debug("Error executing query", "error", err) + return false + } + return isSuperior == 1 + +} + func getMonday(ts time.Time) time.Time { if ts.Weekday() != time.Monday { if ts.Weekday() == time.Sunday { diff --git a/Backend/models/user_test.go b/Backend/models/user_test.go index c214625..0fe0a71 100644 --- a/Backend/models/user_test.go +++ b/Backend/models/user_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -var testUser models.User = models.User{Vorname: "Kim", Name: "Mustermensch", PersonalNummer: 456, CardUID: "aaaa-aaaa", ArbeitszeitPerTag: 8, ArbeitszeitPerWoche: 40} +var testUser models.User = models.User{Vorname: "Kim", Name: "Mustermensch", PersonalNummer: 456, CardUID: "aaaa-aaaa", ArbeitszeitPerTag: 8, ArbeitszeitPerWoche: 35} func SetupUserFixture(t *testing.T, db models.IDatabase) { t.Helper() diff --git a/Backend/models/workDay.go b/Backend/models/workDay.go index 9780ce7..c995275 100644 --- a/Backend/models/workDay.go +++ b/Backend/models/workDay.go @@ -5,68 +5,117 @@ import ( "encoding/json" "fmt" "log" + "log/slog" "sort" "time" ) -type IWorkDay interface { - Date() time.Time - TimeWorkVirtual(User) time.Duration - TimeWorkReal(User) time.Duration - TimePauseReal(User) (work, pause time.Duration) - TimeOvertimeReal(User) time.Duration - GetAllWorkTimesVirtual(User) (work, pause, overtime time.Duration) - ToString() string - IsWorkDay() bool - IsKurzArbeit() bool - GetDayProgress(User) int8 - RequiresAction() bool -} - type WorkDay struct { Day time.Time `json:"day"` Bookings []Booking `json:"bookings"` workTime time.Duration pauseTime time.Duration - realWorkTime time.Duration - realPauseTime time.Duration TimeFrom time.Time TimeTo time.Time kurzArbeit bool kurzArbeitAbsence Absence + // Urlaub untertags + worktimeAbsece Absence } -func GetDays(user User, tsFrom, tsTo time.Time, orderedForward bool) []IWorkDay { - var allDays map[string]IWorkDay = make(map[string]IWorkDay) - var sortedDays []IWorkDay - for _, day := range GetWorkDays(user, tsFrom, tsTo) { - allDays[day.Date().Format("2006-01-02")] = &day +// IsEmpty implements [IWorkDay]. +func (d *WorkDay) IsEmpty() bool { + return len(d.Bookings) == 0 +} + +type WorktimeBase int + +const ( + WorktimeBaseWeek WorktimeBase = 5 + WorktimeBaseDay WorktimeBase = 1 +) + +func (d *WorkDay) GetWorktimeAbsence() Absence { + return d.worktimeAbsece +} + +// 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 { + return d.kurzArbeitAbsence.GetWorktime(u, base, true) } - absences, err := GetAbsencesByCardUID(user.CardUID, tsFrom, tsTo) - if err != nil { - log.Println("Error gettings absences for all Days!", err) - return sortedDays + work, pause := calcWorkPause(d.Bookings) + work, pause = correctWorkPause(work, pause) + if (d.worktimeAbsece != Absence{}) { + work += d.worktimeAbsece.GetWorktime(u, base, false) } - for _, day := range absences { - if helper.IsWeekend(day.Date()) { - continue - } - if day.AbwesenheitTyp.WorkTime == 1 { - if workDay, ok := allDays[day.Date().Format("2006-01-02")].(*WorkDay); ok { - if len(workDay.Bookings) > 0 { - workDay.kurzArbeit = true - workDay.kurzArbeitAbsence = day - } + return work.Round(time.Minute) +} + +// 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 = correctWorkPause(work, pause) + return pause.Round(time.Minute) +} + +// Returns the overtime based on the db entries +func (d *WorkDay) GetOvertime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { + work := d.GetWorktime(u, base, includeKurzarbeit) + var targetHours time.Duration + switch base { + case WorktimeBaseDay: + targetHours = u.ArbeitszeitProTag() + case WorktimeBaseWeek: + targetHours = u.ArbeitszeitProWocheFrac(0.2) + } + return (work - targetHours).Round(time.Minute) +} + +func (d *WorkDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (work, pause, overtime time.Duration) { + return d.GetWorktime(u, base, includeKurzarbeit), d.GetPausetime(u, base, includeKurzarbeit), d.GetOvertime(u, base, includeKurzarbeit) +} + +func calcWorkPause(bookings []Booking) (work, pause time.Duration) { + var lastBooking Booking + for _, b := range bookings { + if b.CheckInOut%2 == 1 { + if !lastBooking.Timestamp.IsZero() { + pause += b.Timestamp.Sub(lastBooking.Timestamp) } } else { - allDays[day.Date().Format("2006-01-02")] = &day + work += b.Timestamp.Sub(lastBooking.Timestamp) } + lastBooking = b + } + if len(bookings)%2 == 1 { + work += time.Since(lastBooking.Timestamp.Local()) + } + return work, pause +} + +func correctWorkPause(workIn, pauseIn time.Duration) (work, pause time.Duration) { + if workIn <= 6*time.Hour || pauseIn > 45*time.Minute { + return workIn, pauseIn } - for _, day := range allDays { + var diff time.Duration + if workIn <= (9*time.Hour) && pauseIn < 30*time.Minute { + diff = 30*time.Minute - pauseIn + } else if pauseIn < 45*time.Minute { + diff = 45*time.Minute - pauseIn + } + work = workIn - diff + pause = pauseIn + diff + return work, pause +} + +func sortDays(days map[string]IWorkDay, forward bool) []IWorkDay { + var sortedDays []IWorkDay + for _, day := range days { sortedDays = append(sortedDays, day) } - if orderedForward { + if forward { sort.Slice(sortedDays, func(i, j int) bool { return sortedDays[i].Date().After(sortedDays[j].Date()) }) @@ -82,72 +131,29 @@ func (d *WorkDay) Date() time.Time { return d.Day } -func (d *WorkDay) TimeWorkVirtual(u User) time.Duration { - if d.IsKurzArbeit() { - return u.ArbeitszeitProTag() +func (d *WorkDay) Type() DayType { + return DayTypeWorkday +} + +func (d *WorkDay) GenerateKurzArbeitBookings(u User) (time.Time, time.Time) { + var timeFrom, timeTo time.Time + if d.GetWorktime(u, WorktimeBaseDay, false) >= u.ArbeitszeitProTag() { + return timeFrom, timeTo } - return d.workTime + 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()) + + return timeFrom, timeTo } func (d *WorkDay) GetKurzArbeit() *Absence { return &d.kurzArbeitAbsence } -func (d *WorkDay) TimeWorkReal(u User) time.Duration { - d.realWorkTime, d.realPauseTime = 0, 0 - var lastBooking Booking - for _, booking := range d.Bookings { - if booking.CheckInOut%2 == 1 { - if !lastBooking.Timestamp.IsZero() { - d.realPauseTime += booking.Timestamp.Sub(lastBooking.Timestamp) - } - } else { - d.realWorkTime += booking.Timestamp.Sub(lastBooking.Timestamp) - } - lastBooking = booking - } - if helper.IsSameDate(d.Date(), time.Now()) && len(d.Bookings)%2 == 1 { - d.realWorkTime += time.Since(lastBooking.Timestamp.Local()) - } - return d.realWorkTime -} - -func (d *WorkDay) TimeOvertimeReal(u User) time.Duration { - workTime := d.TimeWorkVirtual(u) - if workTime == 0 { - workTime, _ = d.TimePauseReal(u) - } - if helper.IsWeekend(d.Day) && len(d.Bookings) == 0 { - return 0 - } - var overtime time.Duration - overtime = workTime - u.ArbeitszeitProTag() - return overtime -} - -func (d *WorkDay) TimePauseReal(u User) (work, pause time.Duration) { - if d.realWorkTime == 0 { - d.TimeWorkReal(u) - } - d.workTime, d.pauseTime = d.realWorkTime, d.realPauseTime - if d.realWorkTime <= 6*time.Hour || d.realPauseTime > 45*time.Minute { - return d.realWorkTime, d.realPauseTime - } - if d.realWorkTime <= (9*time.Hour) && d.realPauseTime < 30*time.Minute { - diff := 30*time.Minute - d.pauseTime - d.workTime -= diff - d.pauseTime += diff - } else if d.realPauseTime < 45*time.Minute { - diff := 45*time.Minute - d.pauseTime - d.workTime -= diff - d.pauseTime += diff - } - return d.workTime, d.pauseTime -} - func (d *WorkDay) ToString() string { - return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s", d.Date().Format("2006-01-02"), len(d.Bookings), helper.FormatDuration(d.workTime)) + return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime)) } func (d *WorkDay) IsWorkDay() bool { @@ -234,7 +240,6 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { return workDays } defer rows.Close() - // emptyDays, _ := strconv.ParseBool(helper.GetEnv("EMPTY_DAYS", "false")) for rows.Next() { var workDay WorkDay var bookings []byte @@ -253,7 +258,6 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 { workDay.Bookings = []Booking{} } - workDay.TimePauseReal(user) if len(workDay.Bookings) > 1 || !helper.IsWeekend(workDay.Date()) { workDays = append(workDays, workDay) } @@ -265,18 +269,6 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay { return workDays } -func (d *WorkDay) GetAllWorkTimesReal(user User) (work, pause, overtime time.Duration) { - if d.pauseTime == 0 || d.workTime == 0 { - d.TimePauseReal(user) - } - return d.workTime.Round(time.Minute), d.pauseTime.Round(time.Minute), d.TimeOvertimeReal(user) -} - -func (d *WorkDay) GetAllWorkTimesVirtual(user User) (work, pause, overtime time.Duration) { - _, pause, overtime = d.GetAllWorkTimesReal(user) - return d.TimeWorkVirtual(user), pause, overtime -} - // returns bool wheter the workday was ended with an automatic logout func (d *WorkDay) RequiresAction() bool { if len(d.Bookings) == 0 { @@ -289,19 +281,7 @@ func (d *WorkDay) GetDayProgress(u User) int8 { if d.RequiresAction() { return -1 } - workTime := d.TimeWorkVirtual(u) + workTime := d.GetWorktime(u, WorktimeBaseDay, true) progress := (workTime.Seconds() / u.ArbeitszeitProTag().Seconds()) * 100 return int8(progress) } - -// func (d *WorkDay) CalcOvertime(user User) time.Duration { -// if d.workTime == 0 { -// d.TimePauseReal(user) -// } -// if helper.IsWeekend(d.Day) && len(d.Bookings) == 0 { -// return 0 -// } -// var overtime time.Duration -// overtime = d.workTime - user.ArbeitszeitProTag() -// return overtime -// } diff --git a/Backend/models/workDay_test.go b/Backend/models/workDay_test.go index abc3d89..9ad0e6d 100644 --- a/Backend/models/workDay_test.go +++ b/Backend/models/workDay_test.go @@ -16,65 +16,147 @@ func CatchError[T any](val T, err error) T { } var testWorkDay = models.WorkDay{ - Day: CatchError(time.Parse("2006-01-02", "2025-01-01")), + Day: CatchError(time.Parse(time.DateOnly, "2025-01-01")), 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")), } -func TestCalcRealWorkTime(t *testing.T) { - workTime := testWorkDay.TimeWorkReal(testUser) - if workTime != time.Hour*8 { - t.Errorf("Calc Worktime Default not working, time should be 8h, but was %s", helper.FormatDuration(workTime)) - } -} - -func TestCalcWorkPauseDiff(t *testing.T) { - type testCase struct { - Name string - bookings []models.Booking - expectedWorkTime time.Duration - expectedPauseTime time.Duration - expectedOvertime time.Duration - } - - testCases := []testCase{testCase{ - Name: "6hrs no pause", - bookings: testBookings6hrs, - expectedWorkTime: 6 * time.Hour, - expectedPauseTime: 0, - expectedOvertime: -2 * time.Hour, - }, - testCase{ - Name: "8hrs - 30min pause", - bookings: testBookings8hrs, - expectedWorkTime: 7*time.Hour + 30*time.Minute, - expectedPauseTime: 30 * time.Minute, - expectedOvertime: -30 * time.Minute, +func TestWorkdayWorktimeDay(t *testing.T) { + testCases := []struct { + testName string + bookings []models.Booking + expectedTime time.Duration + }{ + { + testName: "Bookings6hrs", + bookings: testBookings6hrs, + expectedTime: time.Hour * 6, }, - testCase{ - Name: "10hrs - 45min pause", - bookings: testBookings10hrs, - expectedWorkTime: 9*time.Hour + 15*time.Minute, - expectedPauseTime: 45 * time.Minute, - expectedOvertime: 1*time.Hour + 15*time.Minute, - }} + { + testName: "Bookings8hrs", + bookings: testBookings8hrs, + expectedTime: time.Hour*7 + time.Minute*30, + }, + { + testName: "Bookings10hrs", + bookings: testBookings10hrs, + expectedTime: time.Hour*9 + time.Minute*15, + }, + } - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - testWorkDay.Bookings = test.bookings - testWorkDay.TimeWorkReal(testUser) - testWorkDay.TimePauseReal(testUser) - testWorkDay.TimeOvertimeReal(testUser) - workTime, pauseTime, overTime := testWorkDay.GetAllWorkTimesReal(testUser) - if workTime != test.expectedWorkTime { - t.Errorf("Calculated wrong workTime: should be %s, but was %s", helper.FormatDuration(test.expectedWorkTime), helper.FormatDuration(workTime)) - } - if pauseTime != test.expectedPauseTime { - t.Errorf("Calculated wrong pauseTime: should be %s, but was %s", helper.FormatDuration(test.expectedPauseTime), helper.FormatDuration(pauseTime)) - } - if overTime != test.expectedOvertime { - t.Errorf("Calculated wrong overtime: should be %s, but was %s", helper.FormatDuration(test.expectedOvertime), helper.FormatDuration(overTime)) + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.testName, func(t *testing.T) { + var testCase = testWorkDay + testCase.Bookings = tc.bookings + workTime := testCase.GetWorktime(testUser, models.WorktimeBaseDay, false) + if workTime != tc.expectedTime { + t.Errorf("GetWorktimeReal not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) + } + }) + } +} + +func TestWorkdayWorktimeWeek(t *testing.T) { + testCases := []struct { + testName string + bookings []models.Booking + expectedTime time.Duration + }{ + { + testName: "Bookings6hrs", + bookings: testBookings6hrs, + expectedTime: time.Hour * 6, + }, + { + testName: "Bookings8hrs", + bookings: testBookings8hrs, + expectedTime: time.Hour*7 + time.Minute*30, + }, + { + testName: "Bookings10hrs", + bookings: testBookings10hrs, + expectedTime: time.Hour*9 + time.Minute*15, + }, + } + + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.testName, func(t *testing.T) { + var testCase = testWorkDay + testCase.Bookings = tc.bookings + workTime := testCase.GetWorktime(testUser, models.WorktimeBaseWeek, false) + if workTime != tc.expectedTime { + t.Errorf("GetWorktimeReal not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) + } + }) + } +} + +func TestWorkdayPausetimeDay(t *testing.T) { + testCases := []struct { + testName string + bookings []models.Booking + expectedTime time.Duration + }{ + { + testName: "Bookings6hrs", + bookings: testBookings6hrs, + expectedTime: 0, + }, + { + testName: "Bookings8hrs", + bookings: testBookings8hrs, + expectedTime: time.Minute * 30, + }, + { + testName: "Bookings10hrs", + bookings: testBookings10hrs, + expectedTime: time.Minute * 45, + }, + } + + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.testName, func(t *testing.T) { + var testCase = testWorkDay + testCase.Bookings = tc.bookings + workTime := testCase.GetPausetime(testUser, models.WorktimeBaseDay, false) + if workTime != tc.expectedTime { + t.Errorf("GetPausetimeReal not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) + } + }) + } +} + +func TestWorkdayPausetimeWeek(t *testing.T) { + testCases := []struct { + testName string + bookings []models.Booking + expectedTime time.Duration + }{ + { + testName: "Bookings6hrs", + bookings: testBookings6hrs, + expectedTime: 0, + }, + { + testName: "Bookings8hrs", + bookings: testBookings8hrs, + expectedTime: time.Minute * 30, + }, + { + testName: "Bookings10hrs", + bookings: testBookings10hrs, + expectedTime: time.Minute * 45, + }, + } + + for _, tc := range testCases { + t.Run("Calc Absence Worktime: "+tc.testName, func(t *testing.T) { + var testCase = testWorkDay + testCase.Bookings = tc.bookings + workTime := testCase.GetPausetime(testUser, models.WorktimeBaseWeek, false) + if workTime != tc.expectedTime { + t.Errorf("GetPausetimeReal not working, time should be %s, but was %s", helper.FormatDurationFill(tc.expectedTime, true), helper.FormatDurationFill(workTime, true)) } }) } diff --git a/Backend/models/workWeek.go b/Backend/models/workWeek.go index 634d6fb..168365a 100644 --- a/Backend/models/workWeek.go +++ b/Backend/models/workWeek.go @@ -4,23 +4,25 @@ import ( "database/sql" "errors" "log" + "log/slog" "time" + + "github.com/lib/pq" ) // Workweeks are type WorkWeek struct { - Id int - WorkDays []WorkDay - Absences []Absence - Days []IWorkDay - User User - WeekStart time.Time - Worktime time.Duration - Overtime time.Duration - Status WeekStatus - overtimeDiff time.Duration - worktimeDiff time.Duration + Id int + WorkDays []WorkDay + Absences []Absence + Days []IWorkDay + User User + WeekStart time.Time + Worktime time.Duration + WorktimeVirtual time.Duration + Overtime time.Duration + Status WeekStatus } type WeekStatus int8 @@ -45,16 +47,19 @@ func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek { } func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Duration) { - log.Println("Got Days with overtime and worktime", worktime, overtime) + 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) - log.Println(w.Worktime) for _, day := range w.Days { - log.Println(day.TimeWorkVirtual(w.User)) - w.Worktime += day.TimeWorkVirtual(w.User) + w.Worktime += day.GetWorktime(w.User, WorktimeBaseDay, false) + w.WorktimeVirtual += day.GetWorktime(w.User, WorktimeBaseDay, true) } - log.Println("Calculated new worktime", w.Worktime) - w.Overtime = w.Worktime - w.User.ArbeitszeitProWoche() + slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String()) + + w.Overtime = w.WorktimeVirtual - w.User.ArbeitszeitProWoche() + + slog.Debug("Calculated overtime", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String()) w.Worktime = w.Worktime.Round(time.Minute) w.Overtime = w.Overtime.Round(time.Minute) @@ -65,8 +70,6 @@ func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Durati if overtime != w.Overtime || worktime != w.Worktime { w.Status = WeekStatusDifferences - w.overtimeDiff = overtime - w.worktimeDiff = worktime } } @@ -149,30 +152,70 @@ func (w *WorkWeek) GetSendWeeks(user User) []WorkWeek { var ErrRunningWeek = errors.New("Week is in running week") +func (w *WorkWeek) GetBookingIds() (anwesenheitsIds, abwesenheitsIds []int64, err error) { + qStr, err := DB.Prepare(` + SELECT + (SELECT array_agg(counter_id ORDER BY counter_id) + FROM anwesenheit + WHERE card_uid = $1 + AND timestamp::DATE >= $2 + AND timestamp::DATE < $3) AS anwesenheit, + + (SELECT array_agg(counter_id ORDER BY counter_id) + FROM abwesenheit + WHERE card_uid = $1 + AND datum_from < $3 + AND datum_to >= $2) AS abwesenheit; + `) + if err != nil { + return nil, nil, err + } + defer qStr.Close() + + slog.Debug("Inserting parameters into qStr:", "user card_uid", w.User.CardUID, "week_start", w.WeekStart, "week_end", w.WeekStart.AddDate(0, 0, 5)) + + err = qStr.QueryRow(w.User.CardUID, w.WeekStart, w.WeekStart.AddDate(0, 0, 5)).Scan(pq.Array(&anwesenheitsIds), pq.Array(&abwesenheitsIds)) + if err != nil { + return anwesenheitsIds, abwesenheitsIds, err + } + return anwesenheitsIds, abwesenheitsIds, nil +} + // creates a new entry in the woche_report table with the given workweek func (w *WorkWeek) SendWeek() error { var qStr *sql.Stmt var err error + slog.Info("Sending workWeek to team head", "week", w.WeekStart.String()) + + anwBookings, awBookings, err := w.GetBookingIds() + if err != nil { + slog.Warn("Error querying bookings from work week", slog.Any("error", err)) + return err + } + + slog.Debug("Recieved Booking Ids", "anwesenheiten", anwBookings) + if time.Since(w.WeekStart) < 5*24*time.Hour { - log.Println("Cannot send week, because it's the running week!") + slog.Warn("Cannot send week, because it's the running week!") 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) WHERE personal_nummer = $1 AND woche_start = $2;`) + 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 { - log.Println("Error preparing SQL statement", err) + slog.Warn("Error preparing SQL statement", "error", err) return err } } else { - qStr, err = DB.Prepare(`INSERT INTO wochen_report (personal_nummer, woche_start, arbeitszeit, ueberstunden) VALUES ($1, $2, make_interval(secs => $3::numeric / 1000000000), make_interval(secs => $4::numeric / 1000000000));`) + 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 { - log.Println("Error preparing SQL statement", err) + slog.Warn("Error preparing SQL statement", "error", err) return err } } - _, err = qStr.Exec(w.User.PersonalNummer, w.WeekStart, int64(w.Worktime), int64(w.Overtime)) + + _, 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) return err diff --git a/Backend/models/workWeek_test.go b/Backend/models/workWeek_test.go index cd78cef..ef989e4 100644 --- a/Backend/models/workWeek_test.go +++ b/Backend/models/workWeek_test.go @@ -8,7 +8,7 @@ import ( func SetupWorkWeekFixture(t *testing.T) models.WorkWeek { t.Helper() - monday, err := time.Parse("2006-01-02", "2025-01-10") + monday, err := time.Parse(time.DateOnly, "2025-01-10") if err != nil { t.Fatal(err) } @@ -16,7 +16,7 @@ func SetupWorkWeekFixture(t *testing.T) models.WorkWeek { } func TestNewWorkWeekNoPopulate(t *testing.T) { - monday, err := time.Parse("2006-01-02", "2025-01-10") + monday, err := time.Parse(time.DateOnly, "2025-01-10") if err != nil { t.Fatal(err) } diff --git a/Backend/static/css/styles.css b/Backend/static/css/styles.css index 8b42ab0..6d0ccdd 100644 --- a/Backend/static/css/styles.css +++ b/Backend/static/css/styles.css @@ -12,7 +12,9 @@ --color-red-700: oklch(50.5% 0.213 27.518); --color-orange-500: oklch(70.5% 0.213 47.604); --color-purple-600: oklch(55.8% 0.288 302.321); + --color-slate-600: oklch(44.6% 0.043 257.281); --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); --color-neutral-100: oklch(97% 0 0); --color-neutral-200: oklch(92.2% 0 0); --color-neutral-300: oklch(87% 0 0); @@ -197,18 +199,54 @@ .relative { position: relative; } + .sticky { + position: sticky; + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .top-1 { + top: calc(var(--spacing) * 1); + } + .top-1\/2 { + top: calc(1/2 * 100%); + } + .top-2 { + top: calc(var(--spacing) * 2); + } .top-2\.5 { top: calc(var(--spacing) * 2.5); } + .top-25 { + top: calc(var(--spacing) * 25); + } + .top-26 { + top: calc(var(--spacing) * 26); + } .top-\[0\.125rem\] { top: 0.125rem; } .right-1 { right: calc(var(--spacing) * 1); } + .right-2 { + right: calc(var(--spacing) * 2); + } .right-2\.5 { right: calc(var(--spacing) * 2.5); } + .left-1 { + left: calc(var(--spacing) * 1); + } + .left-1\/2 { + left: calc(1/2 * 100%); + } + .z-10 { + z-index: 10; + } + .z-100 { + z-index: 100; + } .col-span-2 { grid-column: span 2 / span 2; } @@ -236,6 +274,9 @@ .ml-1 { margin-left: calc(var(--spacing) * 1); } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } .icon-\[material-symbols-light--cancel-outline\] { display: inline-block; width: 1.25em; @@ -342,6 +383,9 @@ .inline { display: inline; } + .inline-flex { + display: inline-flex; + } .table { display: table; } @@ -360,6 +404,12 @@ .h-2 { height: calc(var(--spacing) * 2); } + .h-3 { + height: calc(var(--spacing) * 3); + } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -369,6 +419,9 @@ .h-8 { height: calc(var(--spacing) * 8); } + .h-10 { + height: calc(var(--spacing) * 10); + } .h-\[100vh\] { height: 100vh; } @@ -378,12 +431,21 @@ .w-2 { width: calc(var(--spacing) * 2); } + .w-3 { + width: calc(var(--spacing) * 3); + } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } .w-4 { width: calc(var(--spacing) * 4); } .w-5 { width: calc(var(--spacing) * 5); } + .w-9 { + width: calc(var(--spacing) * 9); + } .w-9\/10 { width: calc(9/10 * 100%); } @@ -396,6 +458,9 @@ .w-full { width: 100%; } + .flex-shrink { + flex-shrink: 1; + } .flex-shrink-0 { flex-shrink: 0; } @@ -411,9 +476,34 @@ .basis-\[content\] { flex-basis: content; } + .border-collapse { + border-collapse: collapse; + } + .-translate-x-1 { + --tw-translate-x: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-x-1\/2 { + --tw-translate-x: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .scroll-m-2 { scroll-margin: calc(var(--spacing) * 2); } @@ -547,6 +637,9 @@ .border-neutral-600 { border-color: var(--color-neutral-600); } + .border-slate-800 { + border-color: var(--color-slate-800); + } .bg-accent { background-color: var(--color-accent); } @@ -568,6 +661,9 @@ .bg-red-600 { background-color: var(--color-red-600); } + .mask-repeat { + mask-repeat: repeat; + } .p-1 { padding: calc(var(--spacing) * 1); } @@ -632,12 +728,28 @@ .text-red-600 { color: var(--color-red-600); } + .text-slate-600 { + color: var(--color-slate-600); + } .text-slate-700 { color: var(--color-slate-700); } + .text-white { + color: var(--color-white); + } .uppercase { text-transform: uppercase; } + .underline { + text-decoration-line: underline; + } + .opacity-0 { + opacity: 0%; + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -646,6 +758,11 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } .transition-colors { 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)); @@ -655,6 +772,10 @@ --tw-duration: 300ms; transition-duration: 300ms; } + .select-none { + -webkit-user-select: none; + user-select: none; + } .\*\:text-center { :is(& > *) { text-align: center; @@ -711,11 +832,26 @@ display: none; } } + .peer-checked\:opacity-100 { + &:is(:where(.peer):checked ~ *) { + opacity: 100%; + } + } .placeholder\:text-neutral-400 { &::placeholder { color: var(--color-neutral-400); } } + .checked\:border-slate-800 { + &:checked { + border-color: var(--color-slate-800); + } + } + .checked\:bg-slate-800 { + &:checked { + background-color: var(--color-slate-800); + } + } .hover\:border-neutral-500 { &:hover { @media (hover: hover) { @@ -798,12 +934,6 @@ } } } - .max-md\:border-b-1 { - @media (width < 48rem) { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; - } - } .max-md\:bg-neutral-300 { @media (width < 48rem) { background-color: var(--color-neutral-300); @@ -819,11 +949,6 @@ grid-column: span 3 / span 3; } } - .md\:col-span-4 { - @media (width >= 48rem) { - grid-column: span 4 / span 4; - } - } .md\:mx-\[10\%\] { @media (width >= 48rem) { margin-inline: 10%; @@ -849,6 +974,11 @@ width: calc(1/2 * 100%); } } + .md\:flex-row { + @media (width >= 48rem) { + flex-direction: row; + } + } .md\:px-4 { @media (width >= 48rem) { padding-inline: calc(var(--spacing) * 4); @@ -1011,6 +1141,41 @@ } } } +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-divide-x-reverse { syntax: "*"; inherits: false; @@ -1030,6 +1195,11 @@ syntax: "*"; inherits: false; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1090,10 +1260,19 @@ @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-divide-x-reverse: 0; --tw-border-style: solid; --tw-divide-y-reverse: 0; --tw-font-weight: initial; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/Backend/static/logo.png b/Backend/static/logo.png new file mode 100644 index 0000000..53694f9 Binary files /dev/null and b/Backend/static/logo.png differ diff --git a/Backend/static/script.js b/Backend/static/script.js index 2f3b643..c54487d 100644 --- a/Backend/static/script.js +++ b/Backend/static/script.js @@ -15,7 +15,9 @@ function editWorkday(element, event, id, isWorkDay) { event.preventDefault(); let form = document.getElementById(id); if (form == null) { - form = element.closest(".grid-sub").querySelector(".all-booking-component > form"); + form = element + .closest(".grid-sub") + .querySelector(".all-booking-component > form"); } clearEditState(); @@ -37,10 +39,21 @@ function editWorkday(element, event, id, isWorkDay) { const absenceForm = document.getElementById("absence_form"); if (id == 0) { - absenceForm.querySelector("[name=date_from]").value = form.id.replace("time-", ""); - absenceForm.querySelector("[name=date_to]").value = form.id.replace("time-", ""); + absenceForm.querySelector("[name=date_from]").value = form.id.replace( + "time-", + "", + ); + absenceForm.querySelector("[name=date_to]").value = form.id.replace( + "time-", + "", + ); } else { - syncFields(form, absenceForm, ["date_from", "date_to", "aw_type", "aw_id"]); + syncFields(form, absenceForm, [ + "date_from", + "date_to", + "aw_type", + "aw_id", + ]); } } } @@ -49,11 +62,6 @@ function toggleAbsenceEdit(state) { const form = document.getElementById("absence_form"); if (state) { form.classList.remove("hidden"); - form.scrollIntoView({ - behavior: "smooth", - block: "start", - inline: "nearest", - }); } else { form.classList.add("hidden"); } @@ -79,3 +87,18 @@ function navigateWeek(element, event, direction) { function logoutUser() { fetch("/user/logout", {}).then(() => globalThis.location.reload()); } + +function checkAll(pattern, state) { + for (let input of document.querySelectorAll(`input[id^=${pattern}]`)) { + input.checked = state; + } +} + +bookingForms = document.querySelectorAll("form.bookings"); +for (form of bookingForms) { + let selectKommenInput = form.querySelector("input[name='select_kommen']"); + let kommenGehenSelector = form.querySelector("select"); + if (selectKommenInput) { + kommenGehenSelector.value = selectKommenInput.value == "true" ? 3 : 4; + } +} diff --git a/Backend/template.typ b/Backend/template.typ new file mode 100644 index 0000000..ade04ab --- /dev/null +++ b/Backend/template.typ @@ -0,0 +1,92 @@ +#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/headerComponent.templ b/Backend/templates/headerComponent.templ index ec2d673..abcf693 100644 --- a/Backend/templates/headerComponent.templ +++ b/Backend/templates/headerComponent.templ @@ -1,11 +1,13 @@ package templates templ headerComponent() { + // {{ user := ctx.Value("user").(models.User) }}
Nutzername: { user.Vorname } { user.Name }
Personalnummer: { user.PersonalNummer }
+Arbeitszeit pro Tag: { helper.FormatDuration(user.ArbeitszeitProTag()) }
+Arbeitszeit pro Woche: { helper.FormatDuration(user.ArbeitszeitProWoche()) }
{ user.Vorname } { user.Name }
-Nutzer von Weboberfläche abmelden.
Arbeitszeit pro Tag: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(helper.FormatDuration(user.ArbeitszeitProTag())) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/pages.templ`, Line: 61, Col: 108} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
Arbeitszeit pro Woche: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(helper.FormatDuration(user.ArbeitszeitProWoche())) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/pages.templ`, Line: 62, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Nutzer von Weboberfläche abmelden.
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(user.Vorname) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/pages.templ`, Line: 118, Col: 22} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(user.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/pages.templ`, Line: 118, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Zeitraum: { tsStart.Format("02.01.2006") } - { tsEnd.Format("02.01.2006") }
-Arbeitszeit: { helper.FormatDuration(worktime) }
-Überstunden: { helper.FormatDuration(overtime) }
+ @headerComponent() +