dev/finalFixes #81

Merged
tom_trgr merged 14 commits from dev/finalFixes into main 2026-03-01 10:11:45 +01:00
35 changed files with 1616 additions and 840 deletions

3
.gitignore vendored
View File

@@ -30,7 +30,8 @@ DB/pg_data
.env.* .env.*
.env .env
!.env.example
Docker/config
.idea .idea
.vscode .vscode

View File

@@ -35,7 +35,7 @@ func autoLogout(w http.ResponseWriter) {
fmt.Printf("Error logging out user %v\n", err) fmt.Printf("Error logging out user %v\n", err)
} else { } else {
loggedOutUsers = append(loggedOutUsers, user) loggedOutUsers = append(loggedOutUsers, user)
log.Printf("Automaticaly logged out user %s, %s ", user.Name, user.Vorname) log.Printf("Automaticaly logged out user %d ", user.PersonalNummer)
} }
} }

View File

@@ -21,66 +21,7 @@ import (
const DE_DATE string = "02.01.2006" const DE_DATE string = "02.01.2006"
const FILE_YEAR_MONTH string = "2006_01" const FILE_YEAR_MONTH string = "2006_01"
func convertDaysToTypst(days []models.IWorkDay, u models.User) ([]typstDay, error) { var PDF_DIRECTORY = helper.GetEnv("PDF_PATH", "/doc/")
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) { func PDFCreateController(w http.ResponseWriter, r *http.Request) {
helper.RequiresLogin(Session, w, r) helper.RequiresLogin(Session, w, r)
@@ -101,14 +42,16 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
return return
} }
n := 0 if !helper.IsDebug() {
for _, e := range employes { n := 0
if user.IsSuperior(e) { for _, e := range employes {
employes[n] = e if user.IsSuperior(e) {
n++ employes[n] = e
n++
}
} }
employes = employes[:n]
} }
employes = employes[:n]
reportData := createReports(employes, startDate) reportData := createReports(employes, startDate)
@@ -119,8 +62,9 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
slog.Warn("Could not create pdf report", slog.Any("Error", err)) slog.Warn("Could not create pdf report", slog.Any("Error", err))
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
w.Header().Set("Content-type", "application/pdf") w.Header().Set("Content-type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH))) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=Monatsabrechnung_%s.pdf", startDate.Format(FILE_YEAR_MONTH)))
output.WriteTo(w) output.WriteTo(w)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
case "download": case "download":
@@ -131,11 +75,12 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
} }
output, err := zipPfd(pdfReports, &reportData) output, err := zipPfd(pdfReports, &reportData)
if err != nil { if err != nil {
slog.Warn("Could not create pdf report", slog.Any("Error", err)) slog.Warn("Could not zip pdf reports", slog.Any("Error", err))
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
w.Header().Set("Content-type", "application/zip") w.Header().Set("Content-type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s", startDate.Format(FILE_YEAR_MONTH))) w.Header().Set("Content-Disposition", fmt.Sprintf("attachement; filename=Monatsabrechnung_%s.zip", startDate.Format(FILE_YEAR_MONTH)))
output.WriteTo(w) output.WriteTo(w)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@@ -145,6 +90,75 @@ func PDFCreateController(w http.ResponseWriter, r *http.Request) {
} }
} }
func convertDaysToTypst(days []models.IWorkDay, u models.User, weekbase models.WorktimeBase) ([]typstDay, error) {
var typstDays []typstDay
for i, day := range days {
if !day.IsSubmittedAndAccepted() && !helper.IsDebug() {
continue
}
var thisTypstDay typstDay
workVirtual, pause, overtime := day.GetTimes(u, weekbase, true)
overtime = workVirtual - u.ArbeitszeitProWocheFrac(0.2)
if day.Type() == models.DayTypeHoliday {
// workVirtual = 0
overtime = 0
}
thisTypstDay.Date = day.Date().Format(DE_DATE)
thisTypstDay.Worktime = helper.FormatDurationFill(workVirtual, true)
thisTypstDay.Pausetime = helper.FormatDurationFill(pause, true)
thisTypstDay.Overtime = helper.FormatDurationFill(overtime, true)
thisTypstDay.IsFriday = i == len(days)-1
if work := day.GetWorktime(u, weekbase, false); workVirtual > work {
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(workVirtual-work, true)
} else {
thisTypstDay.Kurzarbeit = helper.FormatDurationFill(0, true)
}
thisTypstDay.DayParts = convertDayToTypstDayParts(day, u, weekbase)
typstDays = append(typstDays, thisTypstDay)
}
return typstDays, nil
}
func convertDayToTypstDayParts(day models.IWorkDay, user models.User, weekBase models.WorktimeBase) []typstDayPart {
var typstDayParts []typstDayPart
switch day.Type() {
case models.DayTypeWorkday:
workDay, _ := day.(*models.WorkDay)
for i := 0; i < len(workDay.Bookings); i += 2 {
var typstDayPart typstDayPart
typstDayPart.BookingFrom = workDay.Bookings[i].Timestamp.Format("15:04")
if i+1 < len(workDay.Bookings) {
typstDayPart.BookingTo = workDay.Bookings[i+1].Timestamp.Format("15:04")
} else {
typstDayPart.BookingTo = workDay.Bookings[i].Timestamp.Format("15:04")
}
typstDayPart.WorkType = workDay.Bookings[i].BookingType.Name
typstDayPart.IsWorkDay = true
typstDayParts = append(typstDayParts, typstDayPart)
}
if day.IsKurzArbeit() {
tsFrom, tsTo := workDay.GenerateKurzArbeitBookings(user, weekBase)
typstDayParts = append(typstDayParts, typstDayPart{
BookingFrom: tsFrom.Format("15:04"),
BookingTo: tsTo.Format("15:04"),
WorkType: "Kurzarbeit",
IsWorkDay: true,
})
}
case models.DayTypeCompound:
for _, c := range day.(*models.CompoundDay).DayParts {
typstDayParts = append(typstDayParts, convertDayToTypstDayParts(c, user, weekBase)...)
}
default:
typstDayParts = append(typstDayParts, typstDayPart{IsWorkDay: false, WorkType: day.ToString()})
}
return typstDayParts
}
func createReports(employes []models.User, startDate time.Time) []typstData { func createReports(employes []models.User, startDate time.Time) []typstData {
startDate = helper.GetFirstOfMonth(startDate) startDate = helper.GetFirstOfMonth(startDate)
endDate := startDate.AddDate(0, 1, -1) endDate := startDate.AddDate(0, 1, -1)
@@ -161,37 +175,49 @@ func createReports(employes []models.User, startDate time.Time) []typstData {
} }
func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) { func createEmployeReport(employee models.User, startDate, endDate time.Time) (typstData, error) {
publicHolidays, err := models.GetHolidaysFromTo(startDate, endDate) // publicHolidays, _ := models.GetHolidaysFromTo(startDate, endDate)
targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)-len(publicHolidays)) targetHoursThisMonth := employee.ArbeitszeitProWocheFrac(.2) * time.Duration(helper.GetWorkingDays(startDate, endDate)) //-len(publicHolidays)
workDaysThisMonth := models.GetDays(employee, startDate, endDate.AddDate(0, 0, 1), false) mondaysThisMonth := helper.GetMondays(helper.GenerateDateRange(startDate, endDate), false)
slog.Debug("Baseline Working hours", "targetHours", targetHoursThisMonth.Hours())
var weeks []models.WorkWeek
var workHours, kurzarbeitHours time.Duration var workHours, kurzarbeitHours time.Duration
for _, day := range workDaysThisMonth {
tmpvirtualHours := day.GetWorktime(employee, models.WorktimeBaseDay, true)
tmpactualHours := day.GetWorktime(employee, models.WorktimeBaseDay, false)
if day.IsKurzArbeit() && tmpvirtualHours > tmpactualHours {
slog.Debug("Adding kurzarbeit to workday", "day", day.Date())
kurzarbeitHours += tmpvirtualHours - tmpactualHours
}
workHours += tmpvirtualHours
}
worktimeBalance := workHours - targetHoursThisMonth
typstDays, err := convertDaysToTypst(workDaysThisMonth, employee) for _, monday := range mondaysThisMonth {
var week models.WorkWeek
if monday.After(startDate) {
week = models.NewWorkWeekSimple(employee, monday, true)
} else if startDate.Sub(monday) < time.Hour*24*6 {
week = models.NewWorkWeek(employee, startDate, monday.Add(6*24*time.Hour), true)
}
workHours += week.WorktimeVirtual
kurzarbeitHours += week.Kurzarbeit
weeks = append(weeks, week)
}
monthOvertime := workHours - targetHoursThisMonth
totalOvertime, err := employee.GetReportedOvertime(endDate)
var typstDays []typstDay
for _, week := range weeks {
weekTypstDays, err := convertDaysToTypst(week.Days, employee, week.WeekBase)
if err != nil {
slog.Error("Error converting days into typst", "error", err)
continue
}
typstDays = append(typstDays, weekTypstDays...)
}
if err != nil { if err != nil {
slog.Warn("Failed to convert to days", slog.Any("error", err)) slog.Error("Cannot retrieve total Overtime", "Error", err)
return typstData{}, err
} }
metadata := typstMetadata{ metadata := typstMetadata{
EmployeeName: fmt.Sprintf("%s %s", employee.Vorname, employee.Name), EmployeeName: fmt.Sprintf("%s %s", employee.Vorname, employee.Name),
TimeRange: fmt.Sprintf("%s - %s", startDate.Format(DE_DATE), endDate.Format(DE_DATE)), TimeRange: fmt.Sprintf("%s - %s", startDate.Format(DE_DATE), endDate.Format(DE_DATE)),
Overtime: helper.FormatDurationFill(worktimeBalance, true), Overtime: helper.FormatDurationFill(monthOvertime, true),
WorkTime: helper.FormatDurationFill(workHours, true), WorkTime: helper.FormatDurationFill(workHours, true),
Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true), Kurzarbeit: helper.FormatDurationFill(kurzarbeitHours, true),
OvertimeTotal: "", OvertimeTotal: helper.FormatDurationFill(totalOvertime+monthOvertime, true),
CurrentTimestamp: time.Now().Format("02.01.2006 - 15:04 Uhr"), 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 return typstData{Meta: metadata, Days: typstDays, FileName: fmt.Sprintf("%s_%s.pdf", startDate.Format(FILE_YEAR_MONTH), employee.Name)}, nil
@@ -202,8 +228,7 @@ func renderPDFSingle(data []typstData) (bytes.Buffer, error) {
var output bytes.Buffer var output bytes.Buffer
typstCLI := typst.CLI{ typstCLI := typst.CLI{
WorkingDirectory: "/doc/", WorkingDirectory: PDF_DIRECTORY,
// ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
} }
if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil { if err := typst.InjectValues(&markup, map[string]any{"data": data}); err != nil {
@@ -230,8 +255,7 @@ func renderPDFMulti(data []typstData) ([]bytes.Buffer, error) {
var outputMulti []bytes.Buffer var outputMulti []bytes.Buffer
typstRender := typst.CLI{ typstRender := typst.CLI{
WorkingDirectory: "/doc/", WorkingDirectory: PDF_DIRECTORY,
// ContainerName: helper.GetEnv("TYPST_CONTAINER", "arbeitszeitmessung-doc-creator"),
} }
for _, d := range data { for _, d := range data {
@@ -273,6 +297,16 @@ func zipPfd(pdfReports []bytes.Buffer, reportData *[]typstData) (bytes.Buffer, e
return zipOutput, err return zipOutput, err
} }
func lenWorkDays(workDays []models.IWorkDay) int {
var lenght int
for _, day := range workDays {
if !day.IsEmpty() || day.IsKurzArbeit() {
lenght += 1
}
}
return lenght
}
type typstMetadata struct { type typstMetadata struct {
TimeRange string `json:"time-range"` TimeRange string `json:"time-range"`
EmployeeName string `json:"employee-name"` EmployeeName string `json:"employee-name"`

View File

@@ -42,7 +42,7 @@ func submitReport(w http.ResponseWriter, r *http.Request) {
return return
} }
workWeek := models.NewWorkWeek(user, weekTs, true) workWeek := models.NewWorkWeekSimple(user, weekTs, true)
switch r.FormValue("method") { switch r.FormValue("method") {
case "send": case "send":
@@ -70,7 +70,7 @@ func showWeeks(w http.ResponseWriter, r *http.Request) {
submissionDate := pp.ParseTimestampFallback("submission_date", time.DateOnly, user.GetLastWorkWeekSubmission()) submissionDate := pp.ParseTimestampFallback("submission_date", time.DateOnly, user.GetLastWorkWeekSubmission())
lastSub := helper.GetMonday(submissionDate) lastSub := helper.GetMonday(submissionDate)
userWeek := models.NewWorkWeek(user, lastSub, true) userWeek := models.NewWorkWeekSimple(user, lastSub, true)
var workWeeks []models.WorkWeek var workWeeks []models.WorkWeek
teamMembers, err := user.GetTeamMembers() teamMembers, err := user.GetTeamMembers()

View File

@@ -10,7 +10,6 @@ import (
"errors" "errors"
"log" "log"
"net/http" "net/http"
"time"
) )
// Relevant for arduino inputs -> creates new Booking from get and put method // Relevant for arduino inputs -> creates new Booking from get and put method
@@ -40,7 +39,7 @@ func createBooking(w http.ResponseWriter, r *http.Request) {
} }
booking := (*models.Booking).FromUrlParams(nil, r.URL.Query()) booking := (*models.Booking).FromUrlParams(nil, r.URL.Query())
booking.Timestamp = time.Now() // booking.Timestamp = time.Now()
if booking.Verify() { if booking.Verify() {
err := booking.Insert() err := booking.Insert()
if errors.Is(models.SameBookingError{}, err) { if errors.Is(models.SameBookingError{}, err) {

View File

@@ -100,7 +100,7 @@ func getBookings(w http.ResponseWriter, r *http.Request) {
} }
aggregatedOvertime += day.GetOvertime(user, models.WorktimeBaseDay, true) aggregatedOvertime += day.GetOvertime(user, models.WorktimeBaseDay, true)
} }
if reportedOvertime, err := user.GetReportedOvertime(); err == nil { if reportedOvertime, err := user.GetReportedOvertime(time.Now()); err == nil {
user.Overtime = (reportedOvertime + aggregatedOvertime).Round(time.Minute) user.Overtime = (reportedOvertime + aggregatedOvertime).Round(time.Minute)
} else { } else {
log.Println("Cannot calculate overtime: ", err) log.Println("Cannot calculate overtime: ", err)
@@ -257,12 +257,6 @@ func updateAbsence(r *http.Request) error {
log.Println("Cannot get Absence for id: ", absenceId, err) log.Println("Cannot get Absence for id: ", absenceId, err)
return err return err
} }
if r.FormValue("action") == "delete" {
log.Println("Deleting Absence!", "Not implemented")
// TODO
//absence.Delete()
return nil
}
if absence.Update(newAbsence) { if absence.Update(newAbsence) {
err = absence.Save() err = absence.Save()
@@ -272,5 +266,4 @@ func updateAbsence(r *http.Request) error {
} }
} }
return nil return nil
} }

View File

@@ -17,8 +17,8 @@ type FileLog struct {
var Logs map[string]FileLog = make(map[string]FileLog) var Logs map[string]FileLog = make(map[string]FileLog)
func NewAudit() (i *log.Logger, close func() error) { func NewAudit() (i *log.Logger, close func() error) {
LOG_FILE := "logs/" + time.Now().Format(time.DateOnly) + ".log" logName := "logs/" + time.Now().Format(time.DateOnly) + ".log"
logFile, err := os.OpenFile(LOG_FILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) logFile, err := os.OpenFile(logName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
log.Panic(err) log.Panic(err)
} }

View File

@@ -20,6 +20,10 @@ func GetEnv(key, fallback string) string {
return fallback return fallback
} }
func IsDebug() bool {
return GetEnv("GO_ENV", "production") == "debug"
}
type CacheItem struct { type CacheItem struct {
value any value any
expiration time.Time expiration time.Time

View File

@@ -4,6 +4,7 @@ package helper
import ( import (
"fmt" "fmt"
"slices"
"time" "time"
) )
@@ -18,6 +19,64 @@ func GetMonday(ts time.Time) time.Time {
return ts return ts
} }
func GetMondays(allDays []time.Time, onlyInRange bool) []time.Time {
var mondays []time.Time
var start, end time.Time
for _, day := range allDays {
mondays = append(mondays, GetMonday(day))
if start.IsZero() || day.Before(start) {
start = day
}
if end.IsZero() || day.After(end) {
end = day
}
}
mondays = slices.Compact(mondays)
if onlyInRange {
return DaysInRange(mondays, start, end)
}
return mondays
}
func DaysInRange(days []time.Time, startDate, endDate time.Time) []time.Time {
filtered := []time.Time{}
startDate = startDate.Add(-time.Minute)
endDate = endDate.Add(time.Minute)
for _, day := range days {
if day.After(startDate) && day.Before(endDate) {
filtered = append(filtered, day)
}
}
return filtered
}
func IsMonday(day time.Time) bool {
return day.Weekday() == time.Monday
}
// GenerateDateRange returns a slice of all dates between start and end (inclusive).
func GenerateDateRange(start, end time.Time) []time.Time {
var dates []time.Time
// Ensure start is before or equal to end
if start.After(end) {
return dates
}
// Normalize times to midnight
current := start.Truncate(time.Hour * 24)
end = end.Truncate(time.Hour * 24)
for !current.After(end) {
dates = append(dates, current)
current = current.AddDate(0, 0, 1) // Add one day
}
return dates
}
func GetFirstOfMonth(ts time.Time) time.Time { func GetFirstOfMonth(ts time.Time) time.Time {
if ts.Day() > 1 { if ts.Day() > 1 {
return ts.AddDate(0, 0, -(ts.Day() - 1)) return ts.AddDate(0, 0, -(ts.Day() - 1))

View File

@@ -26,6 +26,101 @@ func TestGetMonday(t *testing.T) {
} }
} }
func TestIsMonday_ReturnsTrueForMonday(t *testing.T) {
monday := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC)
if !IsMonday(monday) {
t.Errorf("Expected IsMonday to return true for Monday, got false")
}
}
func TestIsMonday_ReturnsFalseForNonMonday(t *testing.T) {
tuesday := time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC)
if IsMonday(tuesday) {
t.Errorf("Expected IsMonday to return false for Tuesday, got true")
}
}
func TestGenerateDateRange(t *testing.T) {
start := time.Date(2026, 2, 9, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 2, 11, 0, 0, 0, 0, time.UTC)
dates := GenerateDateRange(start, end)
if len(dates) != 3 {
t.Fatalf("expected 3 dates, got %d", len(dates))
}
expected := []string{"2026-02-09", "2026-02-10", "2026-02-11"}
for i, d := range dates {
got := d.Format("2006-01-02")
if got != expected[i] {
t.Errorf("expected %s, got %s", expected[i], got)
}
}
}
func TestGetMondays_ReturnsOnlyMondays(t *testing.T) {
startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC)
endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC)
daysInMonth := GenerateDateRange(startDate, endDate)
result := GetMondays(daysInMonth, false)
if len(result) < 5 {
t.Errorf("Expected 5 monday, got %d", len(result))
} else if len(result) > 5 {
t.Errorf("Expected 5 monday, got %d", len(result))
}
if result[0] != time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC) {
t.Errorf("Expected first monday to be %v, got %v", "2025-12-29", result[0])
}
}
func TestGetMondays_ReturnsOnlyMondaysInRange(t *testing.T) {
startDate := time.Date(2026, 01, 01, 0, 0, 0, 0, time.UTC)
endDate := time.Date(2026, 01, 31, 0, 0, 0, 0, time.UTC)
daysInMonth := GenerateDateRange(startDate, endDate)
result := GetMondays(daysInMonth, true)
if len(result) < 4 {
t.Errorf("Expected 4 monday, got %d", len(result))
} else if len(result) > 4 {
t.Errorf("Expected 4 monday, got %d", len(result))
}
if result[0] != time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) {
t.Errorf("Expected first monday to be %v, got %v", "2026-01-05", result[0])
}
}
func TestDaysInRange(t *testing.T) {
days := []time.Time{
time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC), // Tuesday
time.Date(2023, 4, 4, 0, 0, 0, 0, time.UTC), // Wednesday
time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC), // Thursday
time.Date(2023, 4, 6, 0, 0, 0, 0, time.UTC), // Friday
}
start := time.Date(2023, 4, 3, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 4, 5, 0, 0, 0, 0, time.UTC)
daysInRange := DaysInRange(days, start, end)
if len(daysInRange) != 3 {
t.Errorf("Expected 3 days in range, got %d", len(daysInRange))
}
if daysInRange[0] != days[0] {
t.Errorf("Expected first day in range to be %v, got %v", days[0], daysInRange[0])
}
if daysInRange[2] != days[2] {
t.Errorf("Expected third day in range to be %v, got %v", days[2], daysInRange[2])
}
}
func TestFormatDurationFill(t *testing.T) { func TestFormatDurationFill(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string

View File

@@ -24,7 +24,7 @@ func SetCors(w http.ResponseWriter) {
func RequiresLogin(session *scs.SessionManager, w http.ResponseWriter, r *http.Request) { func RequiresLogin(session *scs.SessionManager, w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), "session", session)) r = r.WithContext(context.WithValue(r.Context(), "session", session))
if GetEnv("GO_ENV", "production") == "debug" { if IsDebug() {
return return
} }
if session.Exists(r.Context(), "user") { if session.Exists(r.Context(), "user") {

View File

@@ -38,7 +38,7 @@ func main() {
if err != nil { if err != nil {
slog.Info("No .env file found in directory!") slog.Info("No .env file found in directory!")
} }
if helper.GetEnv("GO_ENV", "production") == "debug" { if helper.IsDebug() {
logLevel.Set(slog.LevelDebug) logLevel.Set(slog.LevelDebug)
envs := os.Environ() envs := os.Environ()
slog.Debug("Debug mode enabled", "Environment Variables", envs) slog.Debug("Debug mode enabled", "Environment Variables", envs)
@@ -52,6 +52,8 @@ func main() {
defer models.DB.(*sql.DB).Close() defer models.DB.(*sql.DB).Close()
models.Options = configure()
if helper.GetEnv("GO_ENV", "production") != "debug" { if helper.GetEnv("GO_ENV", "production") != "debug" {
err = Migrate() err = Migrate()
if err != nil { if err != nil {
@@ -114,3 +116,10 @@ func loggingMiddleware(next http.Handler) http.Handler {
slog.Info("Completet Request", slog.String("Time", time.Since(start).String())) slog.Info("Completet Request", slog.String("Time", time.Since(start).String()))
}) })
} }
func configure() models.BookingOptions {
return models.BookingOptions{
AllowOutOfBounds: helper.GetEnv("BOOKING_OUT_OF_BOUNDS", "false") == "true",
AllowUnknownUser: helper.GetEnv("BOOKING_FOR_UNKNOWN_USER", "false") == "true",
}
}

View File

@@ -13,8 +13,10 @@ package models
// the absence data is based on the entries in the "abwesenheit" database table // the absence data is based on the entries in the "abwesenheit" database table
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"log" "log"
"log/slog"
"time" "time"
) )
@@ -61,7 +63,7 @@ func (a *Absence) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool)
return u.ArbeitszeitProTagFrac(float32(a.AbwesenheitTyp.WorkTime) / 100) return u.ArbeitszeitProTagFrac(float32(a.AbwesenheitTyp.WorkTime) / 100)
case WorktimeBaseWeek: case WorktimeBaseWeek:
if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit { if a.AbwesenheitTyp.WorkTime <= 0 && includeKurzarbeit {
return u.ArbeitszeitProTagFrac(0.2) return u.ArbeitszeitProWocheFrac(0.2)
} else if a.AbwesenheitTyp.WorkTime <= 0 { } else if a.AbwesenheitTyp.WorkTime <= 0 {
return 0 return 0
} }
@@ -295,3 +297,24 @@ func (a *Absence) Delete() error {
_, err = qStr.Exec(a.CounterId) _, err = qStr.Exec(a.CounterId)
return err return err
} }
func (a *Absence) IsSubmittedAndAccepted() bool {
qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE $1 = ANY(abwesenheiten) AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains
if err != nil {
slog.Warn("Error when preparing SQL Statement", "error", err)
return false
}
defer qStr.Close()
var isSubmittedAndChecked bool = false
err = qStr.QueryRow(a.CounterId, a.Date()).Scan(&isSubmittedAndChecked)
if err == sql.ErrNoRows {
// No rows found ==> not even submitted
return false
}
if err != nil {
slog.Warn("Unexpected error when executing SQL Statement", "error", err)
}
return isSubmittedAndChecked
}

View File

@@ -36,6 +36,12 @@ type Booking struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
CounterId int `json:"counter_id"` CounterId int `json:"counter_id"`
BookingType BookingType `json:"anwesenheit_typ"` BookingType BookingType `json:"anwesenheit_typ"`
Valid bool `json:"valid"`
}
type BookingOptions struct {
AllowOutOfBounds bool
AllowUnknownUser bool
} }
type IDatabase interface { type IDatabase interface {
@@ -45,6 +51,8 @@ type IDatabase interface {
var DB IDatabase var DB IDatabase
var Options BookingOptions
func (b *Booking) NewBooking(cardUid string, gereatId int16, checkInOut int16, typeId int8) Booking { func (b *Booking) NewBooking(cardUid string, gereatId int16, checkInOut int16, typeId int8) Booking {
bookingType, err := GetBookingTypeById(typeId) bookingType, err := GetBookingTypeById(typeId)
if err != nil { if err != nil {
@@ -91,31 +99,44 @@ func (b *Booking) Verify() bool {
} else { } else {
b.BookingType.Name = bookingType.Name b.BookingType.Name = bookingType.Name
} }
user, err := GetUserByCardUID(b.CardUID)
if err == sql.ErrNoRows {
log.Println("Cannot find user with given CardUID")
return Options.AllowUnknownUser // if allow do not fail verify if not allow fail verify
}
if err != nil {
slog.Error("Cannot get user from CardUID", "error", err)
return false
}
if bookingOutOfBounds(b, &user) {
auditLog, closeLog := logs.NewAudit()
defer closeLog()
if !Options.AllowOutOfBounds {
return false
}
oldTime := b.Timestamp
if oldTime.IsZero() {
oldTime = time.Now()
}
if b.CheckInOut%2 == 1 && b.CheckInOut < 200 { //kommen Booking
b.Timestamp = user.ArbeitMinStartTime(oldTime)
} else {
b.Timestamp = user.ArbeitMaxEndeTime(oldTime)
}
auditLog.Printf("Buchung (%s) von '%s' außerhalb der regulaeren Zeit. Verschieben der Zeit %s -> %s", b.GetBookingType(), user.CardUID, oldTime.Format(time.TimeOnly), b.Timestamp.Format(time.TimeOnly))
slog.Info("Booking is out of work time bounds, setting time to match worktime bounds", "new_time", b.Timestamp.String(), "old_time", oldTime)
}
return true 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 { func (b *Booking) Insert() error {
if !b.Timestamp.IsZero() {
return b.InsertWithTimestamp()
}
if !checkLastBooking(*b) { if !checkLastBooking(*b) {
return SameBookingError{} return SameBookingError{}
} }
@@ -208,7 +229,7 @@ func (b Booking) Save() {
} }
func (b *Booking) GetBookingType() string { func (b *Booking) GetBookingType() string {
debug := (helper.GetEnv("GO_ENV", "production") == "debug") debug := helper.IsDebug()
switch b.CheckInOut { switch b.CheckInOut {
case 1: //manuelle Änderung case 1: //manuelle Änderung
return "kommen" return "kommen"
@@ -244,20 +265,22 @@ func (b *Booking) Update(nb Booking) {
b.GeraetID = nb.GeraetID b.GeraetID = nb.GeraetID
} }
if b.Timestamp != nb.Timestamp { if b.Timestamp != nb.Timestamp {
auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format("15:04"), nb.Timestamp.Format("15:04)")) auditLog.Printf("Änderung in Buchung %d von '%s': Buchungszeit (%s -> %s).", b.CounterId, b.CardUID, b.Timestamp.Format(time.TimeOnly), nb.Timestamp.Format(time.TimeOnly))
b.Timestamp = nb.Timestamp b.Timestamp = nb.Timestamp
} }
} }
func checkLastBooking(b Booking) bool { func checkLastBooking(b Booking) bool {
var check_in_out int var check_in_out int
slog.Info("Checking with timestamp:", "timestamp", b.Timestamp.String()) var timestamp time.Time
stmt, err := DB.Prepare((`SELECT check_in_out FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp"::DATE <= $2::DATE ORDER BY "timestamp" DESC LIMIT 1;`)) slog.Debug("Checking with timestamp:", "timestamp", b.Timestamp)
stmt, err := DB.Prepare((`SELECT check_in_out, timestamp FROM "anwesenheit" WHERE "card_uid" = $1 AND "timestamp" <= $2 ORDER BY "timestamp" DESC LIMIT 1;`))
if err != nil { if err != nil {
log.Fatalf("Error preparing query: %v", err) log.Fatalf("Error preparing query: %v", err)
return false return false
} }
err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out) err = stmt.QueryRow(b.CardUID, b.Timestamp).Scan(&check_in_out, &timestamp)
slog.Info("Checking last bookings check_in_out", "Check", check_in_out)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return true return true
} }
@@ -265,9 +288,13 @@ func checkLastBooking(b Booking) bool {
log.Println("Error checking last booking: ", err) log.Println("Error checking last booking: ", err)
return false return false
} }
if int16(check_in_out)%2 == b.CheckInOut%2 { if int16(check_in_out)%2 == b.CheckInOut%2 {
return false return false
} }
if timestamp.Equal(b.Timestamp) {
return false
}
return true return true
} }
@@ -276,8 +303,6 @@ func (b *Booking) UpdateTime(newTime time.Time) {
if hour == b.Timestamp.Hour() && minute == b.Timestamp.Minute() { if hour == b.Timestamp.Hour() && minute == b.Timestamp.Minute() {
return return
} }
// TODO: add check for time overlap
var newBooking Booking var newBooking Booking
newBooking.Timestamp = time.Date(b.Timestamp.Year(), b.Timestamp.Month(), b.Timestamp.Day(), hour, minute, 0, 0, b.Timestamp.Location()) newBooking.Timestamp = time.Date(b.Timestamp.Year(), b.Timestamp.Month(), b.Timestamp.Day(), hour, minute, 0, 0, b.Timestamp.Location())
if b.CheckInOut < 3 { if b.CheckInOut < 3 {
@@ -287,14 +312,11 @@ func (b *Booking) UpdateTime(newTime time.Time) {
newBooking.CheckInOut = 4 newBooking.CheckInOut = 4
} }
b.Update(newBooking) b.Update(newBooking)
// TODO Check verify
if b.Verify() { if b.Verify() {
b.Save() b.Save()
} else { } else {
log.Println("Cannot save updated booking!", b.ToString()) log.Println("Cannot save updated booking!", b.ToString())
} }
// b.Verify()
// b.Save()
} }
func (b *Booking) ToString() string { func (b *Booking) ToString() string {
@@ -346,3 +368,12 @@ func GetBookingTypesCached() []BookingType {
} }
return types.([]BookingType) return types.([]BookingType)
} }
func bookingOutOfBounds(b *Booking, u *User) bool {
bookingTime := b.Timestamp
if b.Timestamp.IsZero() {
bookingTime = time.Now()
}
res := bookingTime.Before(u.ArbeitMinStartTime(bookingTime)) || bookingTime.After(u.ArbeitMaxEndeTime(bookingTime))
return res
}

View File

@@ -13,35 +13,250 @@ var testBookingType = models.BookingType{
var testBookings8hrs = []models.Booking{{ var testBookings8hrs = []models.Booking{{
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 1, CheckInOut: 1,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}, { }, {
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 2, CheckInOut: 2,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:00")), Timestamp: time.Date(2025, 01, 01, 16, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}} }}
var testBookings6hrs = []models.Booking{{ var testBookings6hrs = []models.Booking{{
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 1, CheckInOut: 1,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}, { }, {
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 2, CheckInOut: 2,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 14:00")), Timestamp: time.Date(2025, 01, 01, 14, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}} }}
var testBookings10hrs = []models.Booking{{ var testBookings10hrs = []models.Booking{{
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 1, CheckInOut: 1,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}, { }, {
CardUID: "aaaa-aaaa", CardUID: "aaaa-aaaa",
CheckInOut: 2, CheckInOut: 2,
Timestamp: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 18:00")), Timestamp: time.Date(2025, 01, 01, 18, 0, 0, 0, time.UTC),
BookingType: testBookingType, BookingType: testBookingType,
}} }}
var testBookings6hrsBreak30min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 14, 30, 0, 0, time.UTC),
BookingType: testBookingType,
}}
var testBookings610hrsBreak30min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 14, 40, 0, 0, time.UTC),
BookingType: testBookingType,
}}
var testBookings9hrsBreak30min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC),
BookingType: testBookingType,
}}
var testBookings930hrs = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 17, 30, 0, 0, time.UTC),
BookingType: testBookingType,
}}
var testBookings910hrsBreak30min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 30, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 17, 40, 0, 0, time.UTC),
BookingType: testBookingType,
},
}
var testBookings910hrsBreak35min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 35, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC),
BookingType: testBookingType,
},
}
var testBookings945hrs = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 17, 45, 0, 0, time.UTC),
BookingType: testBookingType,
},
}
var testBookings10hrsBreak45min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 18, 00, 0, 0, time.UTC),
BookingType: testBookingType,
},
}
var testBookings1030hrsBreak45min = []models.Booking{
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 8, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 9, 0, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 1,
Timestamp: time.Date(2025, 01, 01, 9, 45, 0, 0, time.UTC),
BookingType: testBookingType,
},
{
CardUID: "aaaa-aaaa",
CheckInOut: 2,
Timestamp: time.Date(2025, 01, 01, 18, 30, 0, 0, time.UTC),
BookingType: testBookingType,
},
}

View File

@@ -17,6 +17,15 @@ type CompoundDay struct {
DayParts []IWorkDay DayParts []IWorkDay
} }
// IsSubmittedAndAccepted implements IWorkDay.
func (c *CompoundDay) IsSubmittedAndAccepted() bool {
var isSubmittedAndAccepted = true
for _, day := range c.DayParts {
isSubmittedAndAccepted = isSubmittedAndAccepted && day.IsSubmittedAndAccepted()
}
return isSubmittedAndAccepted
}
func NewCompondDay(date time.Time, dayParts ...IWorkDay) *CompoundDay { func NewCompondDay(date time.Time, dayParts ...IWorkDay) *CompoundDay {
return &CompoundDay{Day: date, DayParts: dayParts} return &CompoundDay{Day: date, DayParts: dayParts}
} }

View File

@@ -23,6 +23,7 @@ type IWorkDay interface {
GetTimes(User, WorktimeBase, bool) (work, pause, overtime time.Duration) GetTimes(User, WorktimeBase, bool) (work, pause, overtime time.Duration)
GetOvertime(User, WorktimeBase, bool) time.Duration GetOvertime(User, WorktimeBase, bool) time.Duration
IsEmpty() bool IsEmpty() bool
IsSubmittedAndAccepted() bool
} }
type DayType int type DayType int
@@ -54,7 +55,9 @@ func GetDays(user User, tsFrom, tsTo time.Time, orderedForward bool) []IWorkDay
} }
for _, absentDay := range absences { for _, absentDay := range absences {
if weekDay := absentDay.Date().Weekday(); weekDay == time.Saturday || weekDay == time.Sunday {
continue
}
// Check if there is already a day // Check if there is already a day
existingDay, ok := allDays[absentDay.Date().Format(time.DateOnly)] existingDay, ok := allDays[absentDay.Date().Format(time.DateOnly)]
switch { switch {

View File

@@ -19,6 +19,11 @@ type PublicHoliday struct {
worktime int8 worktime int8
} }
// IsSubmittedAndAccepted implements IWorkDay.
func (p *PublicHoliday) IsSubmittedAndAccepted() bool {
return true
}
// IsEmpty implements [IWorkDay]. // IsEmpty implements [IWorkDay].
func (p *PublicHoliday) IsEmpty() bool { func (p *PublicHoliday) IsEmpty() bool {
return false return false

View File

@@ -28,12 +28,14 @@ type User struct {
ArbeitszeitPerTag float32 //`json:"arbeitszeit_per_tag"` ArbeitszeitPerTag float32 //`json:"arbeitszeit_per_tag"`
ArbeitszeitPerWoche float32 //`json:"arbeitszeit_per_woche"` ArbeitszeitPerWoche float32 //`json:"arbeitszeit_per_woche"`
Overtime time.Duration Overtime time.Duration
ArbeitMinStart time.Time
ArbeitMaxEnde time.Time
} }
func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, error) { func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User, error) {
var user User var user User
var err error var err error
if helper.GetEnv("GO_ENV", "production") == "debug" { if helper.IsDebug() {
user, err = GetUserByPersonalNr(123) user, err = GetUserByPersonalNr(123)
} else { } else {
if !Session.Exists(ctx, "user") { if !Session.Exists(ctx, "user") {
@@ -50,50 +52,56 @@ func GetUserFromSession(Session *scs.SessionManager, ctx context.Context) (User,
} }
// Returns the actual overtime for this moment // Returns the actual overtime for this moment
func (u *User) GetReportedOvertime() (time.Duration, error) { func (u *User) GetReportedOvertime(startDate time.Time) (time.Duration, error) {
var overtime time.Duration var overtime time.Duration
qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1;") qStr, err := DB.Prepare("SELECT COALESCE(SUM(EXTRACT(EPOCH FROM ueberstunden) * 1000000000)::BIGINT, 0) AS total_ueberstunden_ns FROM wochen_report WHERE personal_nummer = $1 AND woche_start::DATE <= $2::DATE;")
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer qStr.Close() defer qStr.Close()
err = qStr.QueryRow(u.PersonalNummer).Scan(&overtime) err = qStr.QueryRow(u.PersonalNummer, startDate).Scan(&overtime)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return overtime, nil return overtime, nil
} }
func GetAllUsers() ([]User, error) { func GetUserByCardUID(cardUid string) (User, error) {
qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname,arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten;`)) var user User
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 qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE card_uid = $1;`))
if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil { if err != nil {
log.Println("Error creating user!", err) return user, err
continue
}
users = append(users, user)
} }
if err = rows.Err(); err != nil { err = qStr.QueryRow(cardUid).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde)
return users, nil
if err != nil {
return user, err
} }
return users, nil return user, nil
} }
func (u *User) GetAll() ([]User, error) { func (u *User) ArbeitMinStartTime(date time.Time) time.Time {
qStr, err := DB.Prepare((`SELECT card_uid, vorname, nachname FROM s_personal_daten;`)) if date.Hour() > 0 {
date = date.Truncate(24 * time.Hour).Add(-time.Hour)
}
date = date.Truncate(time.Hour)
slog.Info("Date truncate", "date", date)
return date.Add(time.Hour*time.Duration(u.ArbeitMinStart.Hour()) + time.Minute*time.Duration(u.ArbeitMinStart.Minute()))
}
func (u *User) ArbeitMaxEndeTime(date time.Time) time.Time {
if date.Hour() > 0 {
date = date.Truncate(24 * time.Hour).Add(-time.Hour)
}
date = date.Truncate(time.Hour)
slog.Info("Date truncate", "date", date)
return date.Add(time.Hour*time.Duration(u.ArbeitMaxEnde.Hour()) + time.Minute*time.Duration(u.ArbeitMaxEnde.Minute()))
}
func GetAllUsers() ([]User, error) {
qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten;`))
var users []User var users []User
if err != nil { if err != nil {
return users, err return users, err
@@ -107,7 +115,7 @@ func (u *User) GetAll() ([]User, error) {
for rows.Next() { for rows.Next() {
var user User var user User
if err := rows.Scan(&user.CardUID, &user.Vorname, &user.Name); err != nil { if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil {
log.Println("Error creating user!", err) log.Println("Error creating user!", err)
continue continue
} }
@@ -167,11 +175,11 @@ func (u *User) CheckOut() error {
func GetUserByPersonalNr(personalNummer int) (User, error) { func GetUserByPersonalNr(personalNummer int) (User, error) {
var user User var user User
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = $1;`)) qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = $1;`))
if err != nil { if err != nil {
return user, err return user, err
} }
err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche) err = qStr.QueryRow(personalNummer).Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde)
if err != nil { if err != nil {
return user, err return user, err
@@ -185,7 +193,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) {
return users, errors.New("No personalNumbers provided") return users, errors.New("No personalNumbers provided")
} }
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag, arbeitszeit_per_woche FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`)) qStr, err := DB.Prepare((`SELECT personal_nummer, vorname, nachname, card_uid, arbeitszeit_per_tag, arbeitszeit_per_woche, arbeitszeit_min_start, arbeitszeit_max_ende FROM s_personal_daten WHERE personal_nummer = ANY($1::int[]);`))
if err != nil { if err != nil {
return users, err return users, err
} }
@@ -200,7 +208,7 @@ func GetUserByPersonalNrMulti(personalNummerMulti []int) ([]User, error) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var user User var user User
if err := rows.Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche); err != nil { if err := rows.Scan(&user.PersonalNummer, &user.Vorname, &user.Name, &user.CardUID, &user.ArbeitszeitPerTag, &user.ArbeitszeitPerWoche, &user.ArbeitMinStart, &user.ArbeitMaxEnde); err != nil {
return users, err return users, err
} }
users = append(users, user) users = append(users, user)
@@ -246,6 +254,7 @@ func (u *User) ChangePass(password, newPassword string) (bool, error) {
} }
func (u *User) GetTeamMembers() ([]User, error) { func (u *User) GetTeamMembers() ([]User, error) {
var teamMemberPNrs []int
var teamMembers []User var teamMembers []User
qStr, err := DB.Prepare(`SELECT personal_nummer FROM s_personal_daten WHERE vorgesetzter_pers_nr = $1 ORDER BY "nachname";`) qStr, err := DB.Prepare(`SELECT personal_nummer FROM s_personal_daten WHERE vorgesetzter_pers_nr = $1 ORDER BY "nachname";`)
if err != nil { if err != nil {
@@ -261,12 +270,16 @@ func (u *User) GetTeamMembers() ([]User, error) {
for rows.Next() { for rows.Next() {
var personalNr int var personalNr int
err := rows.Scan(&personalNr) err := rows.Scan(&personalNr)
user, err := GetUserByPersonalNr(personalNr) teamMemberPNrs = append(teamMemberPNrs, personalNr)
if err != nil { if err != nil {
log.Println("Error getting user!") log.Println("Error getting user!")
return teamMembers, err return teamMembers, err
} }
teamMembers = append(teamMembers, user) }
teamMembers, err = GetUserByPersonalNrMulti(teamMemberPNrs)
if err != nil {
log.Println("Error getting users!")
return teamMembers, err
} }
return teamMembers, nil return teamMembers, nil
@@ -292,10 +305,42 @@ func (u *User) GetNextWeek() WorkWeek {
func (u *User) GetLastWorkWeekSubmission() time.Time { func (u *User) GetLastWorkWeekSubmission() time.Time {
var lastSub time.Time var lastSub time.Time
qStr, err := DB.Prepare(` qStr, err := DB.Prepare(`
SELECT COALESCE( SELECT new_week
(SELECT woche_start + INTERVAL '1 week' FROM wochen_report WHERE personal_nummer = $1 ORDER BY woche_start DESC LIMIT 1), FROM (
(SELECT timestamp FROM anwesenheit WHERE card_uid = $2 ORDER BY timestamp LIMIT 1) -- Highest priority
) AS letzte_buchung; SELECT
woche_start AS new_week,
1 AS priority
FROM wochen_report
WHERE personal_nummer = $1
AND bestaetigt IS NULL
UNION ALL
-- Fallback if #1 returns nothing
SELECT
woche_start + INTERVAL '1 week' AS new_week,
2 AS priority
FROM wochen_report wo
WHERE personal_nummer = $1
AND NOT EXISTS (
SELECT 1
FROM wochen_report wi
WHERE wi.woche_start = wo.woche_start + INTERVAL '1 week'
AND wi.personal_nummer = wo.personal_nummer
)
UNION ALL
-- Final fallback
SELECT
timestamp AS new_week,
3 AS priority
FROM anwesenheit
WHERE card_uid = $2
) t
ORDER BY priority, new_week
LIMIT 1;
`) `)
if err != nil { if err != nil {
slog.Debug("Error preparing query statement.", "error", err) slog.Debug("Error preparing query statement.", "error", err)
@@ -311,22 +356,6 @@ func (u *User) GetLastWorkWeekSubmission() time.Time {
return lastSub return lastSub
} }
func (u *User) GetFromCardUID(card_uid string) (User, error) {
user := User{}
var err error
qStr, err := DB.Prepare((`SELECT personal_nummer, card_uid, vorname, nachname, arbeitszeit_per_tag FROM s_personal_daten WHERE card_uid = $1;`))
if err != nil {
return user, err
}
err = qStr.QueryRow(card_uid).Scan(&user.PersonalNummer, &user.CardUID, &user.Vorname, &user.Name, &user.ArbeitszeitPerTag)
if err != nil {
return user, err
}
return user, nil
}
func (u *User) IsSuperior(e User) bool { func (u *User) IsSuperior(e User) bool {
var isSuperior int var isSuperior int
qStr, err := DB.Prepare(`SELECT COUNT(1) FROM s_personal_daten WHERE personal_nummer = $1 AND vorgesetzter_pers_nr = $2`) qStr, err := DB.Prepare(`SELECT COUNT(1) FROM s_personal_daten WHERE personal_nummer = $1 AND vorgesetzter_pers_nr = $2`)
@@ -340,7 +369,6 @@ func (u *User) IsSuperior(e User) bool {
return false return false
} }
return isSuperior == 1 return isSuperior == 1
} }
func getMonday(ts time.Time) time.Time { func getMonday(ts time.Time) time.Time {

View File

@@ -7,12 +7,15 @@ package models
import ( import (
"arbeitszeitmessung/helper" "arbeitszeitmessung/helper"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"sort" "sort"
"time" "time"
"github.com/lib/pq"
) )
type WorkDay struct { type WorkDay struct {
@@ -46,11 +49,10 @@ func (d *WorkDay) GetWorktimeAbsence() Absence {
// Gets the time as is in the db (with corrected pause times) // Gets the time as is in the db (with corrected pause times)
func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration {
if includeKurzarbeit && d.IsKurzArbeit() && len(d.Bookings) > 0 { if includeKurzarbeit && d.IsKurzArbeit() { //&& len(d.Bookings) > 0
return d.kurzArbeitAbsence.GetWorktime(u, base, true) return d.kurzArbeitAbsence.GetWorktime(u, base, true)
} }
work, pause := calcWorkPause(d.Bookings) work, _ := correctWorkPause(getWorkPause(d))
work, pause = correctWorkPause(work, pause)
if (d.worktimeAbsece != Absence{}) { if (d.worktimeAbsece != Absence{}) {
work += d.worktimeAbsece.GetWorktime(u, base, false) work += d.worktimeAbsece.GetWorktime(u, base, false)
} }
@@ -59,7 +61,7 @@ func (d *WorkDay) GetWorktime(u User, base WorktimeBase, includeKurzarbeit bool)
// Gets the corrected pause times based on db entries // Gets the corrected pause times based on db entries
func (d *WorkDay) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration { func (d *WorkDay) GetPausetime(u User, base WorktimeBase, includeKurzarbeit bool) time.Duration {
work, pause := calcWorkPause(d.Bookings) work, pause := getWorkPause(d)
work, pause = correctWorkPause(work, pause) work, pause = correctWorkPause(work, pause)
return pause.Round(time.Minute) return pause.Round(time.Minute)
} }
@@ -81,6 +83,15 @@ func (d *WorkDay) GetTimes(u User, base WorktimeBase, includeKurzarbeit bool) (w
return d.GetWorktime(u, base, includeKurzarbeit), d.GetPausetime(u, base, includeKurzarbeit), d.GetOvertime(u, base, includeKurzarbeit) return d.GetWorktime(u, base, includeKurzarbeit), d.GetPausetime(u, base, includeKurzarbeit), d.GetOvertime(u, base, includeKurzarbeit)
} }
func getWorkPause(d *WorkDay) (work, pause time.Duration) {
//if today calc, else take from db
if d.workTime == 0 && d.pauseTime == 0 && len(d.Bookings) > 0 {
return calcWorkPause(d.Bookings)
} else {
return d.workTime, d.pauseTime
}
}
func calcWorkPause(bookings []Booking) (work, pause time.Duration) { func calcWorkPause(bookings []Booking) (work, pause time.Duration) {
var lastBooking Booking var lastBooking Booking
for _, b := range bookings { for _, b := range bookings {
@@ -105,7 +116,8 @@ func correctWorkPause(workIn, pauseIn time.Duration) (work, pause time.Duration)
} }
var diff time.Duration var diff time.Duration
if workIn <= (9*time.Hour) && pauseIn < 30*time.Minute {
if (workIn+pauseIn) <= (9*time.Hour+30*time.Minute) && pauseIn <= 30*time.Minute {
diff = 30*time.Minute - pauseIn diff = 30*time.Minute - pauseIn
} else if pauseIn < 45*time.Minute { } else if pauseIn < 45*time.Minute {
diff = 45*time.Minute - pauseIn diff = 45*time.Minute - pauseIn
@@ -140,12 +152,21 @@ func (d *WorkDay) Type() DayType {
return DayTypeWorkday return DayTypeWorkday
} }
func (d *WorkDay) GenerateKurzArbeitBookings(u User) (time.Time, time.Time) { func (d *WorkDay) GenerateKurzArbeitBookings(u User, weekBase WorktimeBase) (time.Time, time.Time) {
var timeFrom, timeTo time.Time var timeFrom, timeTo time.Time
if d.GetWorktime(u, WorktimeBaseDay, false) >= u.ArbeitszeitProTag() { if d.GetWorktime(u, WorktimeBaseDay, false) >= u.ArbeitszeitProTag() {
return timeFrom, timeTo return timeFrom, timeTo
} }
if d.IsEmpty() {
switch weekBase {
case WorktimeBaseDay:
return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProTag())
case WorktimeBaseWeek:
return d.Day.Add(time.Hour * 8), d.Day.Add(time.Hour * 8).Add(u.ArbeitszeitProWocheFrac(0.2))
}
}
timeFrom = d.Bookings[len(d.Bookings)-1].Timestamp.Add(time.Minute) timeFrom = d.Bookings[len(d.Bookings)-1].Timestamp.Add(time.Minute)
timeTo = timeFrom.Add(u.ArbeitszeitProTag() - d.GetWorktime(u, WorktimeBaseDay, false)) 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()) slog.Debug("Added duration as Kurzarbeit", "date", d.Date().String(), "duration", timeTo.Sub(timeFrom).String())
@@ -158,7 +179,7 @@ func (d *WorkDay) GetKurzArbeit() *Absence {
} }
func (d *WorkDay) ToString() string { func (d *WorkDay) ToString() string {
return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime)) return fmt.Sprintf("WorkDay: %s with %d bookings and worktime: %s. Is KurzArbeit %v", d.Date().Format(time.DateOnly), len(d.Bookings), helper.FormatDuration(d.workTime), d.IsKurzArbeit())
} }
func (d *WorkDay) IsWorkDay() bool { func (d *WorkDay) IsWorkDay() bool {
@@ -178,97 +199,148 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
var workSec, pauseSec float64 var workSec, pauseSec float64
qStr, err := DB.Prepare(` qStr, err := DB.Prepare(`
WITH all_days AS ( WITH
SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date), all_days AS (
normalized_bookings AS ( SELECT
SELECT * generate_series(
FROM ( $2 ::DATE,
SELECT $3 ::DATE - INTERVAL '1 day',
a.card_uid, INTERVAL '1 day'
a.timestamp, )::DATE AS work_date
a.timestamp::DATE AS work_date, ),
a.check_in_out, all_bookings AS (
a.counter_id, SELECT
a.anwesenheit_typ, a.card_uid,
sat.anwesenheit_name AS anwesenheit_typ_name, a.timestamp,
LAG(a.check_in_out) OVER ( a.timestamp::DATE AS work_date,
PARTITION BY a.card_uid, a.timestamp::DATE a.check_in_out,
ORDER BY a.timestamp a.counter_id,
) AS prev_check a.anwesenheit_typ,
FROM anwesenheit a sat.anwesenheit_name AS anwesenheit_typ_name,
LEFT JOIN s_anwesenheit_typen sat LAG(a.check_in_out) OVER (
ON a.anwesenheit_typ = sat.anwesenheit_id PARTITION BY
WHERE a.card_uid = $1 a.card_uid,
AND a.timestamp::DATE >= $2 a.timestamp::DATE
AND a.timestamp::DATE <= $3 ORDER BY
) t a.timestamp
WHERE prev_check IS NULL OR prev_check <> check_in_out ) AS prev_check,
), LAG(a.timestamp) OVER (
ordered_bookings AS ( PARTITION BY
SELECT a.card_uid,
*, a.timestamp::DATE
LAG(timestamp) OVER ( ORDER BY
PARTITION BY card_uid, work_date a.timestamp
ORDER BY timestamp ) AS prev_timestamp
) AS prev_timestamp FROM
FROM normalized_bookings anwesenheit a
) LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id
SELECT WHERE
d.work_date, a.card_uid = $1
COALESCE(MIN(b.timestamp), NOW()) AS time_from, AND a.timestamp::DATE >= $2::DATE
COALESCE(MAX(b.timestamp), NOW()) AS time_to, AND a.timestamp::DATE <= $3::DATE
COALESCE( ),
EXTRACT(EPOCH FROM SUM( normalized_bookings AS (
CASE SELECT
WHEN b.prev_check IN (1, 3) AND b.check_in_out IN (2, 4, 254) *
THEN b.timestamp - b.prev_timestamp FROM
ELSE INTERVAL '0' all_bookings
END WHERE
)), 0 prev_check IS NULL
) AS total_work_seconds, OR prev_check <> check_in_out
COALESCE( )
EXTRACT(EPOCH FROM SUM( SELECT
CASE d.work_date,
WHEN b.prev_check IN (2, 4, 254) AND b.check_in_out IN (1, 3) COALESCE(MIN(b.timestamp), NOW()) AS time_from,
THEN b.timestamp - b.prev_timestamp COALESCE(MAX(b.timestamp), NOW()) AS time_to,
ELSE INTERVAL '0' EXTRACT(
END EPOCH
)), 0 FROM
) AS total_pause_seconds, SUM(
COALESCE(jsonb_agg(jsonb_build_object( CASE
'check_in_out', b.check_in_out, WHEN b.prev_check IN (1, 3)
'timestamp', b.timestamp, AND b.check_in_out IN (2, 4, 254) THEN b.timestamp - b.prev_timestamp
'counter_id', b.counter_id, ELSE INTERVAL '0'
'anwesenheit_typ', b.anwesenheit_typ, END
'anwesenheit_typ', jsonb_build_object( )
'anwesenheit_id', b.anwesenheit_typ, ) AS total_work_seconds,
'anwesenheit_name', b.anwesenheit_typ_name EXTRACT(
) EPOCH
) ORDER BY b.timestamp), '[]'::jsonb) AS bookings FROM
FROM all_days d SUM(
LEFT JOIN ordered_bookings b ON d.work_date = b.work_date CASE
GROUP BY d.work_date WHEN b.prev_check IN (2, 4, 254)
ORDER BY d.work_date ASC;`) AND b.check_in_out IN (1, 3) THEN b.timestamp - b.prev_timestamp
ELSE INTERVAL '0'
END
)
) AS total_pause_seconds,
jsonb_agg(
jsonb_build_object(
'check_in_out',
b.check_in_out,
'valid',
coalesce(b.check_in_out != b.prev_check, true),
'timestamp',
b.timestamp,
'counter_id',
b.counter_id,
'anwesenheit_typ',
jsonb_build_object(
'anwesenheit_id',
b.anwesenheit_typ,
'anwesenheit_name',
b.anwesenheit_typ_name
)
)
ORDER BY
b.timestamp
) FILTER (
WHERE
b.card_uid IS NOT NULL
) AS bookings
FROM
all_days d
LEFT JOIN all_bookings b ON b.work_date = d.work_date
GROUP BY
d.work_date;
`)
// qStr, err := DB.Prepare(` // qStr, err := DB.Prepare(`
// WITH all_days AS ( // WITH all_days AS (
// SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date), // SELECT generate_series($2::DATE, $3::DATE - INTERVAL '1 day', INTERVAL '1 day')::DATE AS work_date),
// ordered_bookings AS ( // normalized_bookings AS (
// SELECT // SELECT *
// a.timestamp::DATE AS work_date, // FROM (
// a.timestamp, // SELECT
// a.check_in_out, // a.card_uid,
// a.counter_id, // a.timestamp,
// a.anwesenheit_typ, // a.timestamp::DATE AS work_date,
// sat.anwesenheit_name AS anwesenheit_typ_name, // a.check_in_out,
// LAG(a.timestamp) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_timestamp, // a.counter_id,
// LAG(a.check_in_out) OVER (PARTITION BY a.card_uid, a.timestamp::DATE ORDER BY a.timestamp) AS prev_check // a.anwesenheit_typ,
// FROM anwesenheit a // sat.anwesenheit_name AS anwesenheit_typ_name,
// LEFT JOIN s_anwesenheit_typen sat ON a.anwesenheit_typ = sat.anwesenheit_id // LAG(a.check_in_out) OVER (
// WHERE a.card_uid = $1 // PARTITION BY a.card_uid, a.timestamp::DATE
// AND a.timestamp::DATE >= $2 // ORDER BY a.timestamp
// AND a.timestamp::DATE <= $3 // ) AS prev_check
// ) // FROM anwesenheit a
// LEFT JOIN s_anwesenheit_typen sat
// ON a.anwesenheit_typ = sat.anwesenheit_id
// WHERE a.card_uid = $1
// AND a.timestamp::DATE >= $2
// AND a.timestamp::DATE <= $3
// ) t
// WHERE prev_check IS NULL OR prev_check <> check_in_out
// ),
// ordered_bookings AS (
// SELECT
// *,
// LAG(timestamp) OVER (
// PARTITION BY card_uid, work_date
// ORDER BY timestamp
// ) AS prev_timestamp
// FROM normalized_bookings
// )
// SELECT // SELECT
// d.work_date, // d.work_date,
// COALESCE(MIN(b.timestamp), NOW()) AS time_from, // COALESCE(MIN(b.timestamp), NOW()) AS time_from,
@@ -322,26 +394,29 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
var workDay WorkDay var workDay WorkDay
var bookings []byte var bookings []byte
if err := rows.Scan(&workDay.Day, &workDay.TimeFrom, &workDay.TimeTo, &workSec, &pauseSec, &bookings); err != nil { if err := rows.Scan(&workDay.Day, &workDay.TimeFrom, &workDay.TimeTo, &workSec, &pauseSec, &bookings); err != nil {
log.Println("Error scanning row!", err) slog.Error("Error scanning row!", "Error", err)
return workDays return workDays
} }
workDay.workTime = time.Duration(workSec * float64(time.Second)) workDay.workTime = time.Duration(workSec * float64(time.Second))
workDay.pauseTime = time.Duration(pauseSec * float64(time.Second)) workDay.pauseTime = time.Duration(pauseSec * float64(time.Second))
err = json.Unmarshal(bookings, &workDay.Bookings) if bookings != nil {
if err != nil { err = json.Unmarshal(bookings, &workDay.Bookings)
log.Println("Error parsing bookings JSON!", err) if err != nil {
return nil slog.Error("Error parsing bookings JSON!", "Error", err, "Json", bookings)
return nil
}
} }
// better empty day handling // better empty day handling
if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 { // if len(workDay.Bookings) == 1 && workDay.Bookings[0].CounterId == 0 {
workDay.Bookings = []Booking{} // workDay.Bookings = []Booking{}
} // }
if len(workDay.Bookings) >= 1 || !helper.IsWeekend(workDay.Date()) { if len(workDay.Bookings) >= 1 || !helper.IsWeekend(workDay.Date()) {
workDays = append(workDays, workDay) workDays = append(workDays, workDay)
} }
} }
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
log.Println("Error in workday rows!", err) slog.Error("Error in workday rows!", "Error", err)
return workDays return workDays
} }
return workDays return workDays
@@ -349,10 +424,12 @@ func GetWorkDays(user User, tsFrom, tsTo time.Time) []WorkDay {
// returns bool wheter the workday was ended with an automatic logout // returns bool wheter the workday was ended with an automatic logout
func (d *WorkDay) RequiresAction() bool { func (d *WorkDay) RequiresAction() bool {
if len(d.Bookings) == 0 { for i := range d.Bookings {
return false if d.Bookings[i].CheckInOut > 250 {
return true
}
} }
return d.Bookings[len(d.Bookings)-1].CheckInOut == 254 return false
} }
func (d *WorkDay) GetDayProgress(u User) int8 { func (d *WorkDay) GetDayProgress(u User) int8 {
@@ -363,3 +440,38 @@ func (d *WorkDay) GetDayProgress(u User) int8 {
progress := (workTime.Seconds() / u.ArbeitszeitProTag().Seconds()) * 100 progress := (workTime.Seconds() / u.ArbeitszeitProTag().Seconds()) * 100
return int8(progress) return int8(progress)
} }
func (d *WorkDay) IsSubmittedAndAccepted() bool {
var isKurzArbeitAccepted bool
if d.IsKurzArbeit() {
isKurzArbeitAccepted = d.kurzArbeitAbsence.IsSubmittedAndAccepted()
}
if d.IsEmpty() {
return isKurzArbeitAccepted
}
qStr, err := DB.Prepare(`SELECT bestaetigt from wochen_report WHERE anwesenheiten @> $1 AND $2 >= woche_start AND $2 < woche_start + INTERVAL '1 week';`) // @> array contains
if err != nil {
slog.Warn("Error when preparing SQL Statement", "error", err)
return false
}
defer qStr.Close()
var isSubmittedAndChecked bool = false
var bookingsIds []int
for _, booking := range d.Bookings {
bookingsIds = append(bookingsIds, booking.CounterId)
}
err = qStr.QueryRow(pq.Array(bookingsIds), d.Date()).Scan(&isSubmittedAndChecked)
if err == sql.ErrNoRows {
return false
}
if err != nil {
slog.Warn("Unexpected error when executing SQL Statement", "error", err, "BookingsIds", bookingsIds)
}
return isSubmittedAndChecked
}

View File

@@ -16,10 +16,10 @@ func CatchError[T any](val T, err error) T {
} }
var testWorkDay = models.WorkDay{ var testWorkDay = models.WorkDay{
Day: CatchError(time.Parse(time.DateOnly, "2025-01-01")), Day: time.Date(2025, 01, 01, 0, 0, 0, 0, time.Local),
Bookings: testBookings8hrs, Bookings: testBookings8hrs,
TimeFrom: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 08:00")), TimeFrom: time.Date(2025, 01, 01, 8, 0, 0, 0, time.Local),
TimeTo: CatchError(time.Parse("2006-01-02 15:04", "2025-01-01 16:30")), TimeTo: time.Date(2025, 01, 01, 16, 30, 0, 0, time.Local),
} }
func TestWorkdayWorktimeDay(t *testing.T) { func TestWorkdayWorktimeDay(t *testing.T) {
@@ -30,18 +30,63 @@ func TestWorkdayWorktimeDay(t *testing.T) {
}{ }{
{ {
testName: "Bookings6hrs", testName: "Bookings6hrs",
bookings: testBookings6hrs, bookings: testBookings6hrs, //work 6h
expectedTime: time.Hour * 6, expectedTime: time.Hour * 6, //pause 0
}, },
{ {
testName: "Bookings8hrs", testName: "Bookings8hrs",
bookings: testBookings8hrs, bookings: testBookings8hrs, //work 8 pause 0
expectedTime: time.Hour*7 + time.Minute*30, expectedTime: time.Hour*7 + time.Minute*30, //pause 30 --> corrected
}, },
{ {
testName: "Bookings10hrs", testName: "Bookings10hrs",
bookings: testBookings10hrs, bookings: testBookings10hrs, //work 10 pause 0
expectedTime: time.Hour*9 + time.Minute*15, expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> corrected
},
{
testName: "Booking 6h with 30 min Break",
bookings: testBookings6hrsBreak30min, //work 6 pause 30
expectedTime: time.Hour * 6, //pause 30 --> bc real pause
},
{
testName: "Booking 6h 10min with 30 min Break",
bookings: testBookings610hrsBreak30min, //work 6 10 pause 30
expectedTime: time.Hour*6 + time.Minute*10, //pause 30 --> real pause
},
{
testName: "Booking 9h with 30 min Break",
bookings: testBookings9hrsBreak30min, //work 9 pause 30
expectedTime: time.Hour * 9, //pause 30 --> real pause
},
{
testName: "Booking 9h 30min",
bookings: testBookings930hrs, //work 9 30 pause 0
expectedTime: time.Hour * 9, //pause 30 --> corrected
},
{
testName: "Booking 9h 40min with 30min Break",
bookings: testBookings910hrsBreak30min, //work 9 10 pause 30
expectedTime: time.Hour*8 + time.Minute*55, //pause 45 --> real + corrected
},
{
testName: "Booking 9h 40min with 35min Break",
bookings: testBookings910hrsBreak35min, //work 9 10 pause 35
expectedTime: time.Hour * 9, //pause 45 --> real + corrected
},
{
testName: "Booking 9h 45min",
bookings: testBookings945hrs, //work 9 45 pause 0
expectedTime: time.Hour * 9, //pause 45 --> corrected
},
{
testName: "Booking 10h Break 45min",
bookings: testBookings10hrsBreak45min, //work 9 15 pause 45
expectedTime: time.Hour*9 + time.Minute*15, //pause 45 --> real
},
{
testName: "Booking 10h 30min Break 45min",
bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45
expectedTime: time.Hour*9 + time.Minute*45, //pause 45 --> real
}, },
} }
@@ -113,6 +158,51 @@ func TestWorkdayPausetimeDay(t *testing.T) {
bookings: testBookings10hrs, bookings: testBookings10hrs,
expectedTime: time.Minute * 45, expectedTime: time.Minute * 45,
}, },
{
testName: "Booking 6h with 30 min Break",
bookings: testBookings6hrsBreak30min, //work 6 pause 30
expectedTime: time.Minute * 30, //pause 30 --> bc real pause
},
{
testName: "Booking 6h 10min with 30 min Break",
bookings: testBookings610hrsBreak30min, //work 6 10 pause 30
expectedTime: time.Minute * 30, //pause 30 --> real pause
},
{
testName: "Booking 9h with 30 min Break",
bookings: testBookings9hrsBreak30min, //work 9 pause 30
expectedTime: time.Minute * 30, //pause 30 --> real pause
},
{
testName: "Booking 9h 30min",
bookings: testBookings930hrs, //work 9 30 pause 0
expectedTime: time.Minute * 30, //pause 30 --> corrected
},
{
testName: "Booking 9h 40min with 30min Break",
bookings: testBookings910hrsBreak30min, //work 9 10 pause 30
expectedTime: time.Minute * 45, //pause 45 --> real + corrected
},
{
testName: "Booking 9h 40min with 35min Break",
bookings: testBookings910hrsBreak35min, //work 9 10 pause 35
expectedTime: time.Minute * 45, //pause 45 --> real + corrected
},
{
testName: "Booking 9h 45min",
bookings: testBookings945hrs, //work 9 45 pause 0
expectedTime: time.Minute * 45, //pause 45 --> corrected
},
{
testName: "Booking 10h Break 45min",
bookings: testBookings10hrsBreak45min, //work 9 15 pause 45
expectedTime: time.Minute * 45, //pause 45 --> real
},
{
testName: "Booking 10h 30min Break 45min",
bookings: testBookings1030hrsBreak45min, //work 9 45 pause 45
expectedTime: time.Minute * 45, //pause 45 --> real
},
} }
for _, tc := range testCases { for _, tc := range testCases {

View File

@@ -6,6 +6,7 @@ package models
// this type is based on the "wochen_report" table // this type is based on the "wochen_report" table
import ( import (
"arbeitszeitmessung/helper"
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
@@ -24,25 +25,34 @@ type WorkWeek struct {
Days []IWorkDay Days []IWorkDay
User User User User
WeekStart time.Time WeekStart time.Time
weekEnd time.Time
Worktime time.Duration Worktime time.Duration
WorktimeVirtual time.Duration WorktimeVirtual time.Duration
Overtime time.Duration Overtime time.Duration
Status WeekStatus Status WeekStatus
WeekBase WorktimeBase
Kurzarbeit time.Duration
} }
type WeekStatus int8 type WeekStatus int8
const ( const (
WeekStatusNone WeekStatus = iota WeekStatusNone WeekStatus = iota
WeekStatusCorrected
WeekStatusSent WeekStatusSent
WeekStatusAccepted WeekStatusAccepted
WeekStatusDifferences WeekStatusDifferences
) )
func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek { func NewWorkWeekSimple(user User, tsMonday time.Time, populate bool) WorkWeek {
return NewWorkWeek(user, tsMonday, tsMonday.Add(6*24*time.Hour), populate)
}
func NewWorkWeek(user User, tsStart, tsEnd time.Time, populate bool) WorkWeek {
var week WorkWeek = WorkWeek{ var week WorkWeek = WorkWeek{
User: user, User: user,
WeekStart: tsMonday, WeekStart: tsStart,
weekEnd: tsEnd,
Status: WeekStatusNone, Status: WeekStatusNone,
} }
if populate { if populate {
@@ -52,13 +62,25 @@ func NewWorkWeek(user User, tsMonday time.Time, populate bool) WorkWeek {
} }
func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Duration) { func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Duration) {
slog.Debug("Populating Workweek for user", "user", w.User)
slog.Debug("Got Days with overtime and worktime", slog.String("worktime", worktime.String()), slog.String("overtime", overtime.String())) slog.Debug("Got Days with overtime and worktime", slog.String("worktime", worktime.String()), slog.String("overtime", overtime.String()))
w.Days = GetDays(w.User, w.WeekStart, w.WeekStart.Add(6*24*time.Hour), false) w.Days = GetDays(w.User, w.WeekStart, w.weekEnd, false)
slog.Debug("Populating Workweek for user", "user", w.User.Name, "Days", lenWorkDays(w.Days), "Start", w.WeekStart, "End", w.weekEnd, "workdays", helper.GetWorkingDays(w.WeekStart, w.weekEnd))
if lenWorkDays(w.Days) == helper.GetWorkingDays(w.WeekStart, w.weekEnd) {
w.WeekBase = WorktimeBaseWeek
} else {
w.WeekBase = WorktimeBaseDay
}
for _, day := range w.Days { for _, day := range w.Days {
w.Worktime += day.GetWorktime(w.User, WorktimeBaseDay, false) dWorkTime := day.GetWorktime(w.User, w.WeekBase, false)
w.WorktimeVirtual += day.GetWorktime(w.User, WorktimeBaseDay, true) dWorkTimeVirtual := day.GetWorktime(w.User, w.WeekBase, true)
if dWorkTime < dWorkTimeVirtual {
w.Kurzarbeit += dWorkTimeVirtual - dWorkTime
}
w.Worktime += dWorkTime
w.WorktimeVirtual += dWorkTimeVirtual
slog.Debug("Calculated Worktime", "Day", day.ToString(), "worktime", w.Worktime.String())
} }
slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String()) slog.Debug("Got worktime for user", "worktime", w.Worktime.String(), "virtualWorkTime", w.WorktimeVirtual.String())
@@ -78,6 +100,16 @@ func (w *WorkWeek) PopulateWithDays(worktime time.Duration, overtime time.Durati
} }
} }
func lenWorkDays(workDays []IWorkDay) int {
var lenght int
for _, day := range workDays {
if !day.IsEmpty() || day.IsKurzArbeit() {
lenght += 1
}
}
return lenght
}
func (w *WorkWeek) CheckStatus() WeekStatus { func (w *WorkWeek) CheckStatus() WeekStatus {
if w.Status != WeekStatusNone { if w.Status != WeekStatusNone {
return w.Status return w.Status
@@ -86,25 +118,31 @@ func (w *WorkWeek) CheckStatus() WeekStatus {
log.Println("Cannot access Database!") log.Println("Cannot access Database!")
return w.Status return w.Status
} }
qStr, err := DB.Prepare(`SELECT bestaetigt FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`) qStr, err := DB.Prepare(`SELECT bestaetigt, id FROM wochen_report WHERE woche_start = $1::DATE AND personal_nummer = $2;`)
if err != nil { if err != nil {
log.Println("Error preparing SQL statement", err) log.Println("Error preparing SQL statement", err)
return w.Status return w.Status
} }
defer qStr.Close() defer qStr.Close()
var beastatigt bool var beastatigt sql.NullBool
err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt) err = qStr.QueryRow(w.WeekStart, w.User.PersonalNummer).Scan(&beastatigt, &w.Id)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return w.Status return w.Status
} }
slog.Info("Bestätigt query res", "Best", beastatigt, "week", w.Id)
if err != nil { if err != nil {
log.Println("Error querying database", err) log.Println("Error querying database", err)
return w.Status return w.Status
} }
if beastatigt { switch {
case beastatigt.Bool:
w.Status = WeekStatusAccepted w.Status = WeekStatusAccepted
} else { case beastatigt.Valid:
w.Status = WeekStatusSent w.Status = WeekStatusSent
default:
w.Status = WeekStatusCorrected
} }
return w.Status return w.Status
} }
@@ -206,23 +244,33 @@ func (w *WorkWeek) SendWeek() error {
return ErrRunningWeek return ErrRunningWeek
} }
if w.CheckStatus() != WeekStatusNone { switch w.CheckStatus() {
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;`) case WeekStatusNone:
if err != nil {
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, anwesenheiten, abwesenheiten) VALUES ($1, $2, make_interval(secs => $3::numeric / 1000000000), make_interval(secs => $4::numeric / 1000000000), $5, $6);`) 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 { if err != nil {
slog.Warn("Error preparing SQL statement", "error", err) slog.Warn("Error preparing SQL statement", "error", err)
return err return err
} }
case WeekStatusCorrected:
qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = FALSE, arbeitszeit = make_interval(secs => $3::numeric / 1000000000), ueberstunden = make_interval(secs => $4::numeric / 1000000000), anwesenheiten=$5, abwesenheiten=$6 WHERE personal_nummer = $1 AND woche_start = $2;`)
if err != nil {
slog.Warn("Error preparing SQL statement", "error", err)
return err
}
case WeekStatusSent, WeekStatusAccepted:
qStr, err = DB.Prepare(`UPDATE "wochen_report" SET bestaetigt = null WHERE personal_nummer = $1 AND woche_start = $2 AND ($3::numeric IS NULL OR TRUE) AND ($4::numeric IS NULL OR TRUE) AND ($5::int[] IS NULL OR TRUE) AND ($6::int[] IS NULL OR TRUE);`)
if err != nil {
slog.Warn("Error preparing SQL statement", "error", err)
return err
}
} }
_, err = qStr.Exec(w.User.PersonalNummer, w.WeekStart, int64(w.Worktime), int64(w.Overtime), pq.Array(anwBookings), pq.Array(awBookings)) _, err = qStr.Exec(w.User.PersonalNummer, w.WeekStart, int64(w.Worktime), int64(w.Overtime), pq.Array(anwBookings), pq.Array(awBookings))
if err != nil { if err != nil {
log.Println("Error executing query!", err) slog.Error("Error executing query!", "error", err)
return err return err
} }
return nil return nil

View File

@@ -20,7 +20,7 @@ func TestNewWorkWeekNoPopulate(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
workWeek := models.NewWorkWeek(testUser, monday, false) workWeek := models.NewWorkWeekSimple(testUser, monday, false)
if workWeek.User != testUser || workWeek.WeekStart != monday { if workWeek.User != testUser || workWeek.WeekStart != monday {
t.Error("No populate workweek does not have right values!") t.Error("No populate workweek does not have right values!")

View File

@@ -20,7 +20,6 @@
--color-neutral-300: oklch(87% 0 0); --color-neutral-300: oklch(87% 0 0);
--color-neutral-400: oklch(70.8% 0 0); --color-neutral-400: oklch(70.8% 0 0);
--color-neutral-500: oklch(55.6% 0 0); --color-neutral-500: oklch(55.6% 0 0);
--color-neutral-600: oklch(43.9% 0 0);
--color-neutral-700: oklch(37.1% 0 0); --color-neutral-700: oklch(37.1% 0 0);
--color-neutral-800: oklch(26.9% 0 0); --color-neutral-800: oklch(26.9% 0 0);
--color-black: #000; --color-black: #000;
@@ -30,8 +29,6 @@
--text-sm--line-height: calc(1.25 / 0.875); --text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem; --text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25); --text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-md: 0.375rem; --radius-md: 0.375rem;
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
@@ -241,6 +238,9 @@
.-my-1 { .-my-1 {
margin-block: calc(var(--spacing) * -1); margin-block: calc(var(--spacing) * -1);
} }
.my-2 {
margin-block: calc(var(--spacing) * 2);
}
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
@@ -308,6 +308,32 @@
mask-size: 100% 100%; mask-size: 100% 100%;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M7.616 20q-.672 0-1.144-.472T6 18.385V6H5V5h4v-.77h6V5h4v1h-1v12.385q0 .69-.462 1.153T16.384 20zM17 6H7v12.385q0 .269.173.442t.443.173h8.769q.23 0 .423-.192t.192-.424zM9.808 17h1V8h-1zm3.384 0h1V8h-1zM7 6v13z'/%3E%3C/svg%3E"); --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M7.616 20q-.672 0-1.144-.472T6 18.385V6H5V5h4v-.77h6V5h4v1h-1v12.385q0 .69-.462 1.153T16.384 20zM17 6H7v12.385q0 .269.173.442t.443.173h8.769q.23 0 .423-.192t.192-.424zM9.808 17h1V8h-1zm3.384 0h1V8h-1zM7 6v13z'/%3E%3C/svg%3E");
} }
.icon-\[material-symbols-light--edit-calendar-rounded\] {
display: inline-block;
width: 1.25em;
height: 1.25em;
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M5.616 21q-.691 0-1.153-.462T4 19.385V6.615q0-.69.463-1.152T5.616 5h1.769V3.308q0-.23.155-.384q.156-.155.386-.155t.383.155t.153.384V5h7.154V3.27q0-.213.143-.357q.144-.144.357-.144t.356.144t.144.356V5h1.769q.69 0 1.153.463T20 6.616v4.601q0 .213-.144.356t-.357.144t-.356-.144t-.143-.356v-.602H5v8.77q0 .23.192.423t.423.192h5.731q.213 0 .357.144t.143.357t-.143.356t-.357.143zm8.615-.808V19.12q0-.153.056-.296q.055-.144.186-.275l5.09-5.065q.149-.13.306-.19t.315-.062q.172 0 .338.064q.166.065.301.194l.925.944q.123.148.188.308q.064.159.064.319t-.052.322t-.2.31l-5.065 5.066q-.131.13-.275.186q-.143.056-.297.056h-1.073q-.343 0-.575-.232t-.232-.576m5.96-4.177l.925-.956l-.925-.944l-.95.95z'/%3E%3C/svg%3E");
}
.icon-\[material-symbols-light--lock\] {
display: inline-block;
width: 1.25em;
height: 1.25em;
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M6.616 21q-.667 0-1.141-.475T5 19.386v-8.77q0-.666.475-1.14T6.615 9H8V7q0-1.671 1.165-2.835Q10.329 3 12 3t2.836 1.165T16 7v2h1.385q.666 0 1.14.475t.475 1.14v8.77q0 .666-.475 1.14t-1.14.475zM12 16.5q.633 0 1.066-.434q.434-.433.434-1.066t-.434-1.066T12 13.5t-1.066.434Q10.5 14.367 10.5 15t.434 1.066q.433.434 1.066.434M9 9h6V7q0-1.25-.875-2.125T12 4t-2.125.875T9 7z'/%3E%3C/svg%3E");
}
.icon-\[material-symbols-light--more-time\] { .icon-\[material-symbols-light--more-time\] {
display: inline-block; display: inline-block;
width: 1.25em; width: 1.25em;
@@ -350,6 +376,9 @@
.block { .block {
display: block; display: block;
} }
.contents {
display: contents;
}
.flex { .flex {
display: flex; display: flex;
} }
@@ -380,6 +409,10 @@
width: calc(var(--spacing) * 5); width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5); height: calc(var(--spacing) * 5);
} }
.size-6 {
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
.h-2 { .h-2 {
height: calc(var(--spacing) * 2); height: calc(var(--spacing) * 2);
} }
@@ -463,27 +496,12 @@
.appearance-none { .appearance-none {
appearance: none; appearance: none;
} }
.break-after-page {
break-after: page;
}
.auto-rows-min {
grid-auto-rows: min-content;
}
.grid-cols-2 { .grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.grid-cols-5 { .grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
.grid-cols-\[3fr_2fr_2fr_2fr_3fr_3fr_3fr\] {
grid-template-columns: 3fr 2fr 2fr 2fr 3fr 3fr 3fr;
}
.grid-cols-subgrid {
grid-template-columns: subgrid;
}
.grid-rows-6 {
grid-template-rows: repeat(6, minmax(0, 1fr));
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -534,11 +552,6 @@
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
} }
} }
.divide-neutral-300 {
:where(& > :not(:last-child)) {
border-color: var(--color-neutral-300);
}
}
.justify-self-end { .justify-self-end {
justify-self: flex-end; justify-self: flex-end;
} }
@@ -565,18 +578,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 0px; border-width: 0px;
} }
.border-r-0 {
border-right-style: var(--tw-border-style);
border-right-width: 0px;
}
.border-r-1 { .border-r-1 {
border-right-style: var(--tw-border-style); border-right-style: var(--tw-border-style);
border-right-width: 1px; border-right-width: 1px;
} }
.border-b-0 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 0px;
}
.border-dashed { .border-dashed {
--tw-border-style: dashed; --tw-border-style: dashed;
border-style: dashed; border-style: dashed;
@@ -587,9 +592,6 @@
.border-neutral-500 { .border-neutral-500 {
border-color: var(--color-neutral-500); border-color: var(--color-neutral-500);
} }
.border-neutral-600 {
border-color: var(--color-neutral-600);
}
.border-slate-800 { .border-slate-800 {
border-color: var(--color-slate-800); border-color: var(--color-slate-800);
} }
@@ -620,9 +622,6 @@
.p-2 { .p-2 {
padding: calc(var(--spacing) * 2); padding: calc(var(--spacing) * 2);
} }
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-3 { .px-3 {
padding-inline: calc(var(--spacing) * 3); padding-inline: calc(var(--spacing) * 3);
} }
@@ -635,10 +634,6 @@
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-2xl {
font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height));
}
.text-sm { .text-sm {
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height)); line-height: var(--tw-leading, var(--text-sm--line-height));
@@ -660,9 +655,6 @@
.text-black { .text-black {
color: var(--color-black); color: var(--color-black);
} }
.text-neutral-300 {
color: var(--color-neutral-300);
}
.text-neutral-500 { .text-neutral-500 {
color: var(--color-neutral-500); color: var(--color-neutral-500);
} }
@@ -719,18 +711,6 @@
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.\*\:text-center {
:is(& > *) {
text-align: center;
}
}
.\*\:not-print\:p-2 {
:is(& > *) {
@media not print {
padding: calc(var(--spacing) * 2);
}
}
}
.group-hover\:text-black { .group-hover\:text-black {
&:is(:where(.group):hover *) { &:is(:where(.group):hover *) {
@media (hover: hover) { @media (hover: hover) {
@@ -1024,7 +1004,7 @@
border-width: 1px; border-width: 1px;
border-color: var(--color-neutral-800); border-color: var(--color-neutral-800);
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var( --tw-ease, var(--default-transition-timing-function) ); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); transition-duration: var(--tw-duration, var(--default-transition-duration));
} }
input.btn, select.btn { input.btn, select.btn {

View File

@@ -1,92 +0,0 @@
#let table-header(..headers) = {
table.header(
..headers.pos().map(h => strong(h))
)
}
#let abrechnung(meta, days) = {
set page(paper: "a4", margin: (x:1.5cm, y:2.25cm),
footer:[#grid(
columns: (3fr, .65fr),
align: left + horizon,
inset: .5em,
[#meta.EmployeeName -- #meta.TimeRange], grid.cell(rowspan: 2)[#image("static/logo.png")],
[Arbeitszeitrechnung maschinell erstellt am #meta.CurrentTimestamp],
)
])
set text(font: "Noto Sans", size:10pt, fill: luma(10%))
set table(
stroke: 0.5pt + luma(10%),
inset: .5em,
align: center + horizon,
)
show text: it => {
if it.text == "0min"{
text(oklch(70.8%, 0, 0deg))[#it]
}else if it.text.starts-with("-"){
text(red)[#it]
}else{
it
}
}
[= Abrechnung Arbeitszeit -- #meta.EmployeeName]
[Zeitraum: #meta.TimeRange]
table(
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1.25fr),
fill: (x, y) =>
if y == 0 { oklch(87%, 0, 0deg) },
table-header(
[Datum], [Kommen], [Gehen], [Arbeitsart], [Stunden], [Pause], [Überstunden]
),
.. for day in days {
(
[#day.Date],
if day.DayParts.len() == 0{
table.cell(colspan: 3)[Keine Buchungen]
}else if not day.DayParts.first().IsWorkDay{
table.cell(colspan: 3)[#day.DayParts.first().WorkType]
}
else {
table.cell(colspan: 3, inset: 0em)[
#table(
columns: (1fr, 1fr, 1fr),
.. for Zeit in day.DayParts {
(
[#Zeit.BookingFrom],
[#Zeit.BookingTo],
[#Zeit.WorkType],
)
},
)
]
},
[#day.Worktime],
[#day.Pausetime],
[#day.Overtime],
)
if day.IsFriday {
( table.cell(colspan: 7, fill: oklch(87%, 0, 0deg))[Wochenende], ) // note the trailing comma
}
}
)
table(
columns: (3fr, 1fr),
align: right,
inset: (x: .25em, y:.75em),
stroke: none,
table.hline(start: 0, end: 2, stroke: stroke(dash:"dashed", thickness:.5pt)),
[Arbeitszeit :], table.cell(align: left)[#meta.WorkTime],
[Überstunden :], table.cell(align: left)[#meta.Overtime],
[Überstunden :],table.cell(align: left)[#meta.OvertimeTotal],
table.hline(start: 0, end: 2),
)
}

View File

@@ -61,6 +61,8 @@ templ SettingsPage(status int) {
<div class="grid-cell col-span-3"> <div class="grid-cell col-span-3">
<p>Nutzername: <span class="text-neutral-500">{ user.Vorname } { user.Name }</span></p> <p>Nutzername: <span class="text-neutral-500">{ user.Vorname } { user.Name }</span></p>
<p>Personalnummer: <span class="text-neutral-500">{ user.PersonalNummer }</span></p> <p>Personalnummer: <span class="text-neutral-500">{ user.PersonalNummer }</span></p>
<p>Frühester Arbeitsbegin: <span class="text-neutral-500">{ user.ArbeitMinStart.Format("15:06") } Uhr</span></p>
<p>Spätester Arbeitsende: <span class="text-neutral-500">{ user.ArbeitMaxEnde.Format("15:06") } Uhr</span></p>
<p>Arbeitszeit pro Tag: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProTag()) }</span></p> <p>Arbeitszeit pro Tag: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProTag()) }</span></p>
<p>Arbeitszeit pro Woche: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProWoche()) }</span></p> <p>Arbeitszeit pro Woche: <span class="text-neutral-500">{ helper.FormatDuration(user.ArbeitszeitProWoche()) }</span></p>
</div> </div>

View File

@@ -47,6 +47,12 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
<div class="grid grid-cols-5 gap-2 lg:grid-cols-1"> <div class="grid grid-cols-5 gap-2 lg:grid-cols-1">
if !onlyAccept { if !onlyAccept {
<div class="col-span-2"> <div class="col-span-2">
if week.CheckStatus() == models.WeekStatusCorrected {
<span class="flex flex-row gap-2 items-center">
<div class="icon-[material-symbols-light--edit-calendar-rounded]"></div>
laufende Korrektur
</span>
}
<span class="flex flex-row gap-2 items-center"> <span class="flex flex-row gap-2 items-center">
@statusCheckMark(week.CheckStatus(), models.WeekStatusSent) @statusCheckMark(week.CheckStatus(), models.WeekStatusSent)
Gesendet Gesendet
@@ -60,7 +66,7 @@ templ workWeekComponent(week models.WorkWeek, onlyAccept bool) {
<div class="flex flex-row gap-2 col-span-3"> <div class="flex flex-row gap-2 col-span-3">
@timeGaugeComponent(int8(progress), false) @timeGaugeComponent(int8(progress), false)
<div> <div>
<p>Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDuration(week.Worktime)) }</p> <p>Arbeitszeit: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Worktime, true)) }</p>
<p>Überstunden: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Overtime, true)) }</p> <p>Überstunden: { fmt.Sprintf("%s", helper.FormatDurationFill(week.Overtime, true)) }</p>
</div> </div>
</div> </div>
@@ -170,10 +176,12 @@ templ workDayWeekComponent(workDay *models.WorkDay) {
<div class="flex flex-row gap-2 items-center"> <div class="flex flex-row gap-2 items-center">
<span class="icon-[material-symbols-light--schedule-outline] flex-shrink-0"></span> <span class="icon-[material-symbols-light--schedule-outline] flex-shrink-0"></span>
switch { switch {
case !workDay.TimeFrom.Equal(workDay.TimeTo): case !workDay.IsEmpty():
<span>{ workDay.TimeFrom.Format("15:04") }</span> <span>{ workDay.TimeFrom.Format("15:04") }</span>
<span>-</span> <span>-</span>
<span>{ workDay.TimeTo.Format("15:04") }</span> <span>{ workDay.TimeTo.Format("15:04") }</span>
case workDay.IsKurzArbeit():
<span>Kurzarbeit</span>
default: default:
<p>Keine Anwesenheit</p> <p>Keine Anwesenheit</p>
} }

View File

@@ -6,16 +6,22 @@ import (
"time" "time"
) )
templ changeButtonComponent(id string, workDay bool) { templ changeButtonComponent(id string, workDay bool, disabled bool) {
<button class="h-10 change-button-component btn w-auto group/button" type="button" onclick={ templ.JSFuncCall("editWorkday", templ.JSExpression("this"), templ.JSExpression("event"), id, workDay) }> if disabled {
<p class="hidden md:block group-[.edit]/button:hidden">Ändern</p> <button class="h-10 change-button-component btn w-auto group/button" type="button" disabled>
<p class="hidden group-[.edit]/button:md:block">Speichern</p> <p class="hidden md:block group-[.edit]/button:hidden">Ändern</p>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 md:hidden"> </button>
<path class="group-[.edit]/button:hidden md:hidden" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"></path> } else {
<path class="hidden group-[.edit]/button:block md:hidden" d="M12.736 3.97a.733.733 0 0 1 j1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"></path> <button class="h-10 change-button-component btn w-auto group/button" type="button" onclick={ templ.JSFuncCall("editWorkday", templ.JSExpression("this"), templ.JSExpression("event"), id, workDay) }>
</svg> <p class="hidden md:block group-[.edit]/button:hidden">Ändern</p>
</button> <p class="hidden group-[.edit]/button:md:block">Speichern</p>
<button class="h-10 hidden group-[.edit]:flex btn basis-[content] items-center" onclick={ templ.JSFuncCall("clearEditState") }><span class="size-5 icon-[material-symbols-light--cancel-outline]"></span></button> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 md:hidden">
<path class="group-[.edit]/button:hidden md:hidden" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"></path>
<path class="hidden group-[.edit]/button:block md:hidden" d="M12.736 3.97a.733.733 0 0 1 j1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425z"></path>
</svg>
</button>
<button class="h-10 hidden group-[.edit]:flex btn basis-[content] items-center" onclick={ templ.JSFuncCall("clearEditState") }><span class="size-5 icon-[material-symbols-light--cancel-outline]"></span></button>
}
} }
templ newAbsenceComponent() { templ newAbsenceComponent() {
@@ -77,22 +83,22 @@ templ newBookingComponent(d models.IWorkDay) {
templ bookingComponent(booking models.Booking) { templ bookingComponent(booking models.Booking) {
<div> <div>
<p class="text-neutral-500 edit-box"> <p class={ "text-neutral-500 edit-box", templ.KV("text-red-500", !booking.Valid) }>
<span class="text-black group-[.edit]:hidden inline">{ booking.Timestamp.Format("15:04") }</span> <span class="text-black group-[.edit]:hidden inline">{ booking.Timestamp.Format("15:04") }</span>
<input disabled name={ "booking_" + strconv.Itoa(booking.CounterId) } type="time" value={ booking.Timestamp.Format("15:04") } class="text-neutral-700 group-[.edit]:inline hidden bg-neutral-100 text-sm px-3 py-2 cursor-pointer"/> <input disabled name={ "booking_" + strconv.Itoa(booking.CounterId) } type="time" value={ booking.Timestamp.Format("15:04") } class="text-neutral-700 group-[.edit]:inline hidden bg-neutral-100 text-sm px-3 py-2 cursor-pointer"/>
{ booking.GetBookingType() } { booking.GetBookingType() }
if !booking.Valid {
fehlerhafte Buchung, wird nicht zur Berechnung verwendet!
}
</p> </p>
if booking.IsSubmittedAndChecked() {
<p>submitted</p>
}
</div> </div>
} }
templ workdayComponent(workDay *models.WorkDay) { templ workdayComponent(workDay *models.WorkDay) {
if len(workDay.Bookings) < 1 { if workDay.IsEmpty() && !workDay.IsKurzArbeit() {
<p class="text group-[.edit]:hidden">Keine Buchung gefunden. Bitte Arbeitsstunden oder Grund der Abwesenheit eingeben!</p> <p class="text group-[.edit]:hidden">Keine Buchung gefunden. Bitte Arbeitsstunden oder Grund der Abwesenheit eingeben!</p>
} else { } else {
if workDay.IsKurzArbeit() && len(workDay.Bookings) > 0 { if workDay.IsKurzArbeit() {
@absenceComponent(workDay.GetKurzArbeit(), true) @absenceComponent(workDay.GetKurzArbeit(), true)
} }
for _, booking := range workDay.Bookings { for _, booking := range workDay.Bookings {

View File

@@ -142,8 +142,11 @@ templ defaultDayComponent(day models.IWorkDay) {
<input type="hidden" name="action" value="change"/> <!-- default action value for ändern button --> <input type="hidden" name="action" value="change"/> <!-- default action value for ändern button -->
</form> </form>
</div> </div>
<div class="grid-cell flex flex-row gap-2 items-end"> <div class="grid-cell flex flex-row gap-2 items-end ">
@changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true) @changeButtonComponent("time-"+day.Date().Format(time.DateOnly), true, day.IsSubmittedAndAccepted())
if day.IsSubmittedAndAccepted() {
<span class="size-6 my-2 icon-[material-symbols-light--lock]"></span>
}
</div> </div>
</div> </div>
} }

View File

@@ -1,6 +1,7 @@
# cron-timing: 05 01 * * 1 # cron-timing: 05 01 * * 1
container_name="arbeitszeitmessung-main-db-1" container_name="arbeitszeitmessung-main-db-1"
filename=backup-$(date '+%d%m%Y').sql filename=backup-$(date '+%Y%m%d').sql
backup_folder=__BACKUP_FOLDER__
database_name=__DATABASE__ database_name=__DATABASE__
docker exec $container_name pg_dump $database_name > /home/pi/arbeitszeitmessung-backup/$filename docker exec $container_name pg_dump $database_name > $backup_folder/$filename
echo "created backup file: "$filename echo "created backup file: "$filename

View File

@@ -1,3 +1,4 @@
# cron-timing: 01 00 01 01 *
# Calls endpoint to write all public Holidays for the current year inside a database. # Calls endpoint to write all public Holidays for the current year inside a database.
port=__PORT__ port=__PORT__
curl localhost:$port/auto/feiertage curl localhost:$port/auto/feiertage

View File

@@ -1,56 +0,0 @@
#!/bin/bash
set -e # Exit on error
echo "Creating PostgreSQL user and setting permissions... $POSTGRES_USER for API user $POSTGRES_API_USER"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE ROLE migrate LOGIN ENCRYPTED PASSWORD '$POSTGRES_PASSWORD';
GRANT USAGE, CREATE ON SCHEMA public TO migrate;
GRANT CONNECT ON DATABASE arbeitszeitmessung TO migrate;
EOSQL
# psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
# GRANT SELECT, INSERT, UPDATE ON anwesenheit, abwesenheit, user_password, wochen_report, s_feiertage TO $POSTGRES_API_USER;
# GRANT DELETE ON abwesenheit TO $POSTGRES_API_USER;
# GRANT SELECT ON s_personal_daten, s_abwesenheit_typen, s_anwesenheit_typen, s_feiertage TO $POSTGRES_API_USER;
# GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $POSTGRES_API_USER;
# EOSQL
echo "User creation and permissions setup complete!"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- privilege roles
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_base') THEN
CREATE ROLE app_base NOLOGIN;
END IF;
END
\$\$;
-- dynamic login role
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_API_USER') THEN
CREATE ROLE $POSTGRES_API_USER
LOGIN
ENCRYPTED PASSWORD '$POSTGRES_API_PASS';
END IF;
END
\$\$;
-- grant base privileges
GRANT app_base TO $POSTGRES_API_USER;
GRANT CONNECT ON DATABASE $POSTGRES_DB TO $POSTGRES_API_USER;
GRANT USAGE ON SCHEMA public TO $POSTGRES_API_USER;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
EOSQL
# psql -v ON_ERROR_STOP=1 --username root --dbname arbeitszeitmessung

View File

@@ -1,12 +1,16 @@
POSTGRES_USER=root # Postgres ADMIN Nutzername POSTGRES_USER=root # Postgres ADMIN Nutzername. regex:^\w+$
POSTGRES_PASSWORD=very_secure # Postgres ADMIN Passwort POSTGRES_PASSWORD=very_secure # Postgres ADMIN Passwort
POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung) POSTGRES_API_USER=api_nutzer # Postgres API Nutzername (für Arbeitszeitmessung). regex:^\w+$
POSTGRES_API_PASS=password # Postgres API Passwort (für Arbeitszeitmessung) POSTGRES_API_PASS=password # Postgres API Passwort (für Arbeitszeitmessung)
POSTGRES_PATH=__ROOT__/DB # Datebank Pfad (relativ zu Docker Ordner oder absoluter pfad mit /...) POSTGRES_PATH=__ROOT__/DB # Datebank Pfad (relativ zu Docker Ordner oder absoluter pfad mit /...)
POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name POSTGRES_DB=arbeitszeitmessung # Postgres Datenbank Name. regex:^[a-z]+$
POSTGRES_PORT=127.0.0.1:5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$ POSTGRES_PORT=5432 # Postgres Port normalerweise nicht freigegeben. regex:^[0-9]{1,5}$
TZ=Europe/Berlin # Zeitzone TZ=Europe/Berlin # Zeitzone
API_TOKEN=dont_access # API Token für ESP Endpoints API_TOKEN=dont_access # API Token für ESP32 Endpoints
WEB_PORT=8000 # Port unter welchem Webserver erreichbar ist. regex:^[0-9]{1,5}$ WEB_PORT=8000 # Port unter welchem Webserver erreichbar ist. regex:^[0-9]{1,5}$
LOG_PATH=__ROOT__/logs # Pfad für Audit Logs LOG_PATH=__ROOT__/logs # Pfad für Audit Logs
LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen LOG_LEVEL=warn # Welche Log-Nachrichten werden in der Konsole erscheinen. regex:^(debug|info|warn|error)$
BACKUP_FOLDER=__ROOT__/backup # Pfad für DB Backup Datein
BOOKING_OUT_OF_BOUNDS=true # Buchungen außerhalb der festgelegten Arbeitszeit erlauben und auf Arbeitszeit anpassen. regex:^(true|false)$
BOOKING_FOR_UNKNOWN_USER=true # Buchungen mit unbekannter CardUID erlauben. regex:^(true|false)$

208
Readme.md
View File

@@ -2,128 +2,148 @@
[![Quality Gate Status](https://sonar.letsstein.de/api/project_badges/measure?project=arbeitszeitmessung&metric=alert_status&token=sqb_253028eff30aff24f32b437cd6c484c511b5c33f)](https://sonar.letsstein.de/dashboard?id=arbeitszeitmessung) [![Quality Gate Status](https://sonar.letsstein.de/api/project_badges/measure?project=arbeitszeitmessung&metric=alert_status&token=sqb_253028eff30aff24f32b437cd6c484c511b5c33f)](https://sonar.letsstein.de/dashboard?id=arbeitszeitmessung)
bis jetzt ein einfaches Backend mit PostgreSQL Datenbank und GO Webserver um Arbeitszeitbuchungen per HTTP PUT einzufügen ---
Eine open-source Software zur Arbeitszeitmessung
## Features
- manuelle Korrektur von einzelnen Buchungen
- Buchung von benutzerdefinierten Abwesenheiten
- automatische gesetzlicher Feiertage
- Pflege eigener Feiertage
- wöchentliches Abrechnungssystem
- Kontrolle der Arbeitszeiten durch direkte Führungskraft
- Ausgabe der Arbeitszeiten je Monat in PDF Format
- Anwesenheitsübersicht
## Installation ## Installation
```bash ```bash
git clone https://git.letsstein.de/tom/arbeitszeitmessung arbeitszeitmessung git clone https://git.letsstein.de/tom/arbeitszeitmessung arbeitszeitmessung
cd arbeitszeitmessung/Docker cd arbeitszeitmessung
# .env Datei anpassen
docker compose up -d ./install.sh
``` ```
## PREVIEW ### Konfiguration:
Zeitverwaltungsansicht (/time): - Datenbank
- `POSTGRES_USER` Postgres ADMIN Nutzername
- `POSTGRES_PASSWORD` Postgres ADMIN Passwort
- `POSTGRES_API_USER` Postgres API Nutzername für Webanwendung
- `POSTGRES_API_PASS` Postgres API Passwort für Webanwendung
- `POSTGRES_PATH` Datebank Pfad
- `POSTGRES_DB` Postgres Datenbank Name
- `POSTGRES_PORT` Postgres Port für administration
- System
- `TZ` Zeitzone
- `LOG_LEVEL` Welche Log-Nachrichten werden in der Konsole erscheinen
- Web/API
- `API_TOKEN` API Token für ESP Endpoints
- `WEB_PORT` Port unter welchem Webserver erreichbar ist
- Ordnerstruktur
- `BACKUP_FOLDER` Pfad für DB Backup Datein
- `LOG_PATH` Pfad für Audit Logs
![time](docs/images/time.png) ## Administration:
Ansicht der Führungskraft (/team): ### Nutzer erstellen:
![team](docs/images/team.png) Nutzerdaten erstellen:
Nutzeransicht (/user): ```sql
INSERT INTO "s_personal_daten"
![user](docs/images/user.png) (
"personal_nummer",
## Buchungstypen "vorname",
"nachname",
1 - Kommen "card_uid",
2 - Gehen "geburtsdatum",
3 - Kommen Manuell "geschlecht",
4 - Gehen Manuell "adresse",
254 - Automatisch abgemeldet "plz",
"hauptbeschaeftigungs_ort",
## API "aktiv_beschaeftigt",
"vorgesetzter_pers_nr",
Nutzung der API "arbeitszeit_min_start",
wenn die `dev-docker-compose.yml` Datei gestartet wird, ist direkt ein SwaggerUI Server mit entsprechender Datei inbegriffen. "arbeitszeit_max_ende",
"arbeitszeit_per_tag",
### Buchungen [/time] "arbeitszeit_per_woche",
)
#### [GET] Anfrage VALUES (
1,
Parameter: cardID (string) 'Max',
Antwort: `200` 'Mustermann',
'acde-edca',
```json '2003-02-01',
[ 1,
{ 'Musterstr. 42',
"cradID": "test_card", '00001',
"readerID": "test_reader", 1,
"bookingTyp": 2, true,
"loggedTime": "2024-09-05T08:37:53.117641Z", 123,
"id": 5 '07:00:00',
}, '20:00:00',
{ 8,
"cradID": "test_card", 40
"readerID": "mytest", );
"bookingTyp": 1,
"loggedTime": "2024-09-05T08:51:12.670827Z",
"id": 6
}
]
``` ```
Antwort `500` Nutzerpasswort generieren (kann auch später als Passwort reset genutzt werden):
Serverfehler
#### [PUT] Anfrage ```sql
INSERT INTO "user_password"
Parameter: id (int) ("personal_nummer", "pass_hash")
Body: (veränderte Parameter) VALUES (123, crypt('password', gen_salt('bf')));
```json
{
"cradID": "test_card",
"readerID": "mytest",
"bookingTyp": 1,
"loggedTime": "2024-09-05T08:51:12.670827Z"
}
``` ```
Antwort `200` ### Buchungstypen erstellen:
```json Ohne definierte Anwesenheits und Abwesenheitstypen funktioniert die Anwendung nicht!
{
"cradID": "test_card", Anwesenheiten:
"readerID": "mytest",
"bookingTyp": 1, ```sql
"loggedTime": "2024-09-05T08:51:12.670827Z", INSERT INTO "s_anwesenheit_typen"
"id": 6 ("anwesenheit_id", "anwesenheit_name")
} VALUES (1, 'Büro');
``` ```
### Neue Buchung [/time/new] Abwesenheiten:
#### [PUT] Anfrage ```sql
INSERT INTO "s_abwesenheit_typen"
Parameter: ("abwesenheit_id", "abwesenheit_name", "arbeitszeit_equivalent")
VALUES (1, 'Urlaub', 100);
- cardID (string)
- readerID (string)
- bookingType (string)
Antwort `202` Akzeptiert und eingefügt
```json
{
"cradID": "test_card",
"readerID": "mytest",
"bookingTyp": 1,
"loggedTime": "2024-09-05T08:51:12.670827Z",
"id": 6
}
``` ```
Antwort `409` Konflikt ### Feiertage erstellen:
Die vorherige Buchung am selben Tag hat den gleichen Buchungstyp
Die gesetzlichen Feiertage für Deutschland/Sachsen werden automatisch mit der Route `auto/feiertage` für das aktuelle Kalenderjahr erzeugt. Um weitere Unternehmensspezifische Feiertage (z.B. 24.12. oder 31.12.) mit in die Liste der Feiertage aufzunehmen, müssen diese manuell erstellt werden.
```sql
INSERT INTO "s_feiertage"
("datum", "name", "arbeitszeit_equivalent", "wiederholen")
VALUES ('2026-12-24', 'Helligabend', 50, 1);
```
Wenn `wiederholen` == 1 wird der Feiertag automatisch beim Aufruf von `auto/feiertage` mit ins nächste Jahr (am selben Datum) übernommen.
Das Feld `arbeitszeit_equivalent` `arbeitszeit_equivalent` ist die prozentuelle Zeit am Tag welche durch diesen Eintrag eingenommen wird. (dies gilt auch für die [Buchungstypen](#buchungstypen-erstellen))
Alle weiteren Tabellen sollte ausschließlich über die Weboberfläche oder per API befüllt werden.
---
# Filestrukture # Filestrukture
``` ```
├── Backend (Webserver) ├── Backend (Webserver)
│   ├── doc (Templates for Document Creator --> typst used to create PDF Reports) │   ├── doc (Templates for Document Creator --> typst used to create PDF Reports)
│   │   ├── static │   │   ├── static

View File

@@ -1,186 +1,327 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#©Tom Tröger 2026
set -e set -e
envFile=Docker/.env envFile=Docker/.env
envBkp=Docker/.env.old
envExample=Docker/env.example envExample=Docker/env.example
autoBackupScript=Cron/autoBackup.sh cronFilePath=Cron
autoHolidaysScript=Cron/autoHolidays.sh customCronFilePath=Docker/config/cron
autoLogoutScript=Cron/autoLogout.sh
echo "Checking Docker installation..." autoBackupScript=autoBackup.sh
if ! command -v docker >/dev/null 2>&1; then autoHolidaysScript=autoHolidays.sh
echo "Docker not found. Install Docker? [y/N]" autoLogoutScript=autoLogout.sh
read -r install_docker
if [[ "$install_docker" =~ ^[Yy]$ ]]; then function checkDocker() {
curl -fsSL https://get.docker.com | sh echo "Checking Docker installation..."
if ! command -v docker >/dev/null 2>&1; then
echo "Docker not found. Install Docker? [y/N]"
read -r install_docker
if [[ "$install_docker" =~ ^[Yy]$ ]]; then
curl -fsSL https://get.docker.com | sh
else
echo "Docker is required. Exiting."
exit 1
fi
else
echo "Docker is already installed."
fi
###########################################################################
echo "Checking Docker Compose..."
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose plugin missing. You may need to update Docker."
exit 1
fi
}
###########################################################################
function setupConfig() {
local reconfig=false
if [ $# -gt 0 ]; then
if ask_reconfig $1 "Reconfigure .env File?"
then
reconfig=true
else else
echo "Docker is required. Exiting." return 0
exit 1
fi fi
else fi
echo "Docker is already installed." echo -e "\r\n==================================================\r\n"
fi echo "Preparing .env file..."
if [ ! -f $envFile ] || [ $reconfig == true ]; then
########################################################################### if [ -f $envExample ]; then
if [ $reconfig == true ]; then
echo "Checking Docker Compose..." echo "Reconfiguring env file. Backup stored at $envBkp"
if ! docker compose version >/dev/null 2>&1; then echo "All previous values will be used as defaults!"
echo "Docker Compose plugin missing. You may need to update Docker." cp $envFile $envBkp
exit 1 else
fi echo ".env not found. Creating interactively from .env.example."
###########################################################################
echo "Preparing .env file..."
if [ ! -f $envFile ]; then
if [ -f $envExample ]; then
echo ".env not found. Creating interactively from .env.example."
> $envFile
while IFS= read -r line; do
#ignore empty lines and comments
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
key=$(printf "%s" "$line" | cut -d '=' -f 1)
rest=$(printf "%s" "$line" | cut -d '=' -f 2-)
# extract inline comment portion
comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p')
raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//')
default_value=$(printf "%s" "$raw_val" | sed 's/"//g')
# Replace __ROOT__ with script pwd
default_value="${default_value/__ROOT__/$(pwd)}"
regex=""
if [[ "$comment" =~ regex:(.*)$ ]]; then
regex="${BASH_REMATCH[1]}"
fi
comment=$(printf "%s" "$comment" | sed 's/ regex:.*//')
while true; do
if [ -z "$comment" ]; then
printf "Value for $key - $comment (default: $default_value"
else
printf "Value for $key (default: $default_value"
fi
if [ -n "$regex" ]; then
printf ", must match: %s" "$regex"
fi
printf "):\n"
read user_input < /dev/tty
# empty input -> take default
[ -z "$user_input" ] && user_input="$default_value"
printf "\e[A$user_input\n"
# validate
if [ -n "$regex" ]; then
if [[ "$user_input" =~ $regex ]]; then
echo "$key=$user_input" >> $envFile
break
else
printf "Invalid value. Does not match regex: %s\n" "$regex"
continue
fi
else
echo "$key=$user_input" >> $envFile
break
fi
done
done < $envExample
echo ".env created."
else
echo "No .env or .env.example found."
echo "Creating an empty .env file for manual editing."
touch $envFile
fi
else
echo "Using existing .env. (found at $envFile)"
fi
###########################################################################
LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2)
if [ -z "$LOG_PATH" ]; then
echo "LOG_PATH not found in .env using default $(pwd)/logs"
LOG_PATH=$(pwd)/logs
else
LOG_PATH=Docker/$LOG_PATH
fi
mkdir -p $LOG_PATH
echo "Created logs folder at $LOG_PATH"
###########################################################################
echo -e "\n\n"
echo "Start containers with docker compose up -d? [y/N]"
read -r start_containers
if [[ "$start_containers" =~ ^[Yy]$ ]]; then
cd Docker
docker compose up -d
echo "Containers started."
else
echo "You can start them manually with: docker compose up -d"
fi
###########################################################################
echo -e "\n\n"
echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]"
read -r setup_cron
if [[ "$setup_cron" =~ ^[Yy]$ ]]; then
WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2)
if [ -z "$WEB_PORT" ]; then
echo "WEB_PORT not found in .env using default 8000"
WEB_PORT=8000
fi
POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2)
if [ -z "$POSTGRES_DB" ]; then
echo "arbeitszeitmessung not found in .env using default arbeitszeitmessung"
POSTGRES_DB="arbeitszeitmessung"
fi
sed -i "s/__PORT__/$WEB_PORT/" $autoHolidaysScript
sed -i "s/__PORT__/$WEB_PORT/" $autoLogoutScript
sed -i "s/__DATABASE__/$POSTGRES_DB/" $autoBackupScript
chmod +x $autoBackupScript $autoHolidaysScript $autoLogoutScript
# echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!"
echo "Adding rules to crontab."
cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX)
for file in Cron/*; do
cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//')
if [ -z "$cron_timing" ]; then
echo "No cron-timing found in $file, so it's not added to crontab."
continue
fi fi
> $envFile
( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab -
echo "Added entry to crontab: $cron_timing $(pwd)/$file."
sleep 2
done
if systemctl is-active --quiet cron.service ; then while IFS= read -r line; do
echo "cron.service is running. Everything should be fine now."
else #ignore empty lines and comments
echo "cron.service is not running. Please start and enable cron.service." [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service"
local key=$(printf "%s" "$line" | cut -d '=' -f 1)
local rest=$(printf "%s" "$line" | cut -d '=' -f 2-)
# extract inline comment portion
local comment=$(printf "%s" "$rest" | sed -n 's/.*# \(.*\)$/\1/p')
local raw_val=$(printf "%s" "$rest" | sed 's/ *#.*//')
local default_value=$(printf "%s" "$raw_val" | sed 's/"//g')
if [ $reconfig == true ]; then
local previous_value=$(grep -E "^$key=" $envBkp | cut -d= -f2)
if [ -n "$previous_value" ]; then
default_value=$previous_value
fi
fi
# Replace __ROOT__ with script pwd
local default_value="${default_value/__ROOT__/$(pwd)}"
regex=""
if [[ "$comment" =~ regex:(.*)$ ]]; then
regex="${BASH_REMATCH[1]}"
fi
comment=$(printf "%s" "$comment" | sed 's/ regex:.*//')
while true; do
if [ -z "$comment" ]; then
printf "Value for $key (default: $default_value"
else
printf "Value for $key - $comment (default: $default_value"
fi
if [ -n "$regex" ]; then
printf ", must match: %s" "$regex"
fi
printf "):\n"
read user_input < /dev/tty
# empty input -> take default
[ -z "$user_input" ] && user_input="$default_value"
printf "\e[A$user_input\n"
# validate
if [ -n "$regex" ]; then
if [[ "$user_input" =~ $regex ]]; then
echo "$key=$user_input" >> $envFile
break
else
printf "Invalid value. Does not match regex: %s\n" "$regex"
continue
fi
else
echo "$key=$user_input" >> $envFile
break
fi
done
done < $envExample
echo ".env created."
else
echo "No .env or .env.example found."
echo "Creating an empty .env file for manual editing."
touch $envFile
fi
else
echo "Using existing .env. (found at $envFile)"
fi
}
###########################################################################
function setupFolders(){
if [ $# -gt 0 ]; then
if ! ask_reconfig $1 "Recreate Folders?"
then
return 0
fi fi
fi
LOG_PATH=$(grep -E '^LOG_PATH=' $envFile | cut -d= -f2)
if [ -z "$LOG_PATH" ]; then
echo "LOG_PATH not found in .env using default $(pwd)/logs"
LOG_PATH=$(pwd)/logs
fi
if [ ! -d "$LOG_PATH" ]; then
mkdir -p $LOG_PATH
echo "Created logs folder at $LOG_PATH"
fi
else POSTGRES_PATH=$(grep -E '^POSTGRES_PATH=' $envFile | cut -d= -f2)
echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!" if [ -z "$POSTGRES_PATH" ]; then
fi echo "POSTGRES_PATH not found in .env using default $(pwd)/DB"
POSTGRES_PATH=$(pwd)/DB
fi
if [ ! -d "$POSTGRES_PATH" ]; then
mkdir -p $POSTGRES_PATH
echo "Created DB folder at $POSTGRES_PATH"
fi
BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2)
if [ -z "$BACKUP_FOLDER" ]; then
echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup"
BACKUP_FOLDER=$(pwd)/backup
fi
if [ ! -d "$BACKUP_FOLDER" ]; then
mkdir -p $BACKUP_FOLDER
echo "Created backup folder at $BACKUP_FOLDER"
fi
}
###########################################################################
function setupCron(){
echo -e "\r\n==================================================\r\n"
echo "Setup Crontab for automatic logout, backup and holiday creation? [y/N]"
read -r setup_cron
if [[ "$setup_cron" =~ ^[Yy]$ ]]; then
echo "Copying custom cron files to $customCronFilePath"
mkdir -p "$customCronFilePath"
if [ ! -s "$customCronFilePath/$autoBackupScript" ];then
cp "$cronFilePath/$autoBackupScript" "$customCronFilePath/$autoBackupScript"
echo "Copied $autoBackupScript"
fi
if [ ! -s "$customCronFilePath/$autoLogoutScript" ];then
cp "$cronFilePath/$autoLogoutScript" "$customCronFilePath/$autoLogoutScript"
echo "Copied $autoLogoutScript"
fi
if [ ! -s "$customCronFilePath/$autoHolidaysScript" ];then
cp "$cronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoHolidaysScript"
echo "Copied $autoHolidaysScript"
fi
WEB_PORT=$(grep -E '^WEB_PORT=' $envFile | cut -d= -f2)
if [ -z "$WEB_PORT" ]; then
echo "WEB_PORT not found in .env using default 8000"
WEB_PORT=8000
fi
POSTGRES_DB=$(grep -E '^POSTGRES_DB=' $envFile | cut -d= -f2)
if [ -z "$POSTGRES_DB" ]; then
echo "POSTGRES_DB not found in .env using default arbeitszeitmessung"
POSTGRES_DB="arbeitszeitmessung"
fi
BACKUP_FOLDER=$(grep -E '^BACKUP_FOLDER=' $envFile | cut -d= -f2)
if [ -z "$BACKUP_FOLDER" ]; then
echo "BACKUP_FOLDER not found in .env using default $(pwd)/backup"
BACKUP_FOLDER="$(pwd)/backup"
fi
sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoHolidaysScript && \
sed -i "s|__PORT__|$WEB_PORT|" $customCronFilePath/$autoLogoutScript && \
sed -i "s|__DATABASE__|$POSTGRES_DB|" $customCronFilePath/$autoBackupScript && \
sed -i "s|__BACKUP_FOLDER__|$BACKUP_FOLDER|" $customCronFilePath/$autoBackupScript
chmod +x "$customCronFilePath/$autoBackupScript" "$customCronFilePath/$autoHolidaysScript" "$customCronFilePath/$autoLogoutScript"
# echo "Scripts build with PORT=$WEB_PORT and DATABSE=$POSTGRES_DB!"
echo "Adding rules to crontab."
cron_commands=$(mktemp /tmp/arbeitszeitmessung-cron.XXX)
pwd
for file in $customCronFilePath/*; do
cron_timing=$(grep -E '^# cron-timing:' "$file" | sed 's/^# cron-timing:[[:space:]]*//')
if [ -z "$cron_timing" ]; then
echo "No cron-timing found in $file, so it's not added to crontab."
continue
fi
( crontab -l ; echo "$cron_timing $(pwd)/$file" )| awk '!x[$0]++' | crontab -
echo "Added entry to crontab: $cron_timing $(pwd)/$file."
done
if systemctl is-active --quiet cron.service ; then
echo "cron.service is running. Everything should be fine now."
else
echo "cron.service is not running. Please start and enable cron.service."
echo "For how to start a service, see: https://wiki.ubuntuusers.de/systemd/systemctl UNITNAME will be cron.service"
fi
else
echo "Please setup cron manually by executing crontab -e and adding all files from inside the Cron directory!"
fi
}
###########################################################################
function startContainer(){
echo -e "\r\n==================================================\r\n"
echo "Start containers with docker compose up -d? [y/N]"
read -r start_containers
if [[ "$start_containers" =~ ^[Yy]$ ]]; then
cd Docker
docker compose up -d
echo "Containers started."
else
echo "You can start them manually with: docker compose up -d"
fi
}
###########################################################################
function help(){
echo "Installer Script für Arbeitszeitmessung Software"
echo -e "\r\n==================================================\r\n"
echo "Nutzung: ./install.sh [options]"
echo -e "\r\n==================================================\r\n"
echo "Optionen:"
echo " -h zeigt diese Übersicht"
echo " -c .env Datei bearbeiten/aktualisieren && cron neu configurieren"
echo -e "\r\n=================================================="
}
###########################################################################
function main(){
echo -e "================Arbeitszeitmessung================\r\n"
if [ $# -gt 0 ];then
if [ $1 == reconfig ]; then
echo -e "================Reconfiguring================\r\n"
setupConfig $1
setupFolders $1
setupCron $1
fi
else
checkDocker
setupConfig
setupFolders
setupCron
startContainer
fi
echo "Installation finished, you can re-run the script any time!"
}
###########################################################################
function ask_reconfig(){
echo -e "\r\n==================================================\r\n"
echo "$2 [y/N]"
read -r do_reconfig
[[ "$do_reconfig" =~ ^[Yy]$ ]] && return # true
echo "Skipping..."
return 1
}
###########################################################################
while getopts ":hc" opt; do
case $opt in
h) help; exit 0 ;;
c) main reconfig; exit 0 ;;
*) echo "Ungültiges Argument"; exit 1 ;;
esac
done
main