feat: add schoolyear based calculation

This commit is contained in:
Patryk Hegenberg 2025-11-06 07:18:23 +01:00
parent e65ba85c43
commit c07019e3eb
5 changed files with 675 additions and 44 deletions

View file

@ -66,6 +66,14 @@ func createTables(db *sql.DB) {
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS school_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
}
for _, query := range queries {
@ -92,6 +100,8 @@ func createIndexes(db *sql.DB) {
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
}
for _, idx := range indexes {
@ -349,56 +359,56 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
return result, nil
}
func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) {
users, err := GetAllUsers(db)
if err != nil {
return nil, err
}
// func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) {
// users, err := GetAllUsers(db)
// if err != nil {
// return nil, err
// }
entries, err := GetAllTimeEntries(db)
if err != nil {
return nil, err
}
// entries, err := GetAllTimeEntries(db)
// if err != nil {
// return nil, err
// }
userTotals := make(map[int]float64)
usernames := make(map[int]string)
// userTotals := make(map[int]float64)
// usernames := make(map[int]string)
for _, entry := range entries {
var hours float64
if entry.Type == "lesson" {
hours = 1.0
} else {
hours = calculateHoursDiff(entry.StartTime, entry.EndTime)
}
userTotals[entry.UserID] += hours
usernames[entry.UserID] = entry.Username
}
// for _, entry := range entries {
// var hours float64
// if entry.Type == "lesson" {
// hours = 1.0
// } else {
// hours = calculateHoursDiff(entry.StartTime, entry.EndTime)
// }
// userTotals[entry.UserID] += hours
// usernames[entry.UserID] = entry.Username
// }
var result []WeeklyHours
for _, user := range users {
if !user.IsAdmin {
total := userTotals[user.ID]
remaining := user.YearlyHours - total
// var result []WeeklyHours
// for _, user := range users {
// if !user.IsAdmin {
// total := userTotals[user.ID]
// remaining := user.YearlyHours - total
result = append(result, WeeklyHours{
UserID: user.ID,
Username: user.Username,
Year: time.Now().Year(),
Week: 0,
TotalHours: total,
YearlyTarget: user.YearlyHours,
YearlyActual: total,
RemainingYearly: remaining,
})
}
}
// result = append(result, WeeklyHours{
// UserID: user.ID,
// Username: user.Username,
// Year: time.Now().Year(),
// Week: 0,
// TotalHours: total,
// YearlyTarget: user.YearlyHours,
// YearlyActual: total,
// RemainingYearly: remaining,
// })
// }
// }
sort.Slice(result, func(i, j int) bool {
return result[i].Username < result[j].Username
})
// sort.Slice(result, func(i, j int) bool {
// return result[i].Username < result[j].Username
// })
return result, nil
}
// return result, nil
// }
func calculateHoursDiff(startTime, endTime string) float64 {
parseTime := func(timeStr string) float64 {
@ -469,3 +479,130 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo
return count > 0, nil
}
func GetActiveSchoolYear(db *sql.DB) (*SchoolYear, error) {
var sy SchoolYear
err := db.QueryRow(`
SELECT id, name, start_date, end_date, is_active, created_at
FROM school_years
WHERE is_active = 1
`).Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil // Kein aktives Schuljahr
}
return &sy, err
}
func GetAllSchoolYears(db *sql.DB) ([]SchoolYear, error) {
rows, err := db.Query(`
SELECT id, name, start_date, end_date, is_active, created_at
FROM school_years
ORDER BY start_date DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
years := []SchoolYear{}
for rows.Next() {
var sy SchoolYear
if err := rows.Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt); err != nil {
continue
}
years = append(years, sy)
}
return years, rows.Err()
}
func CreateSchoolYear(db *sql.DB, name, startDate, endDate string) error {
_, err := db.Exec(`
INSERT INTO school_years (name, start_date, end_date, is_active)
VALUES (?, ?, ?, 0)
`, name, startDate, endDate)
return err
}
func SetActiveSchoolYear(db *sql.DB, id int) error {
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec("UPDATE school_years SET is_active = 0"); err != nil {
tx.Rollback()
return err
}
if _, err := tx.Exec("UPDATE school_years SET is_active = 1 WHERE id = ?", id); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) {
schoolYear, err := GetActiveSchoolYear(db)
if err != nil || schoolYear == nil {
return []WeeklyHours{}, err
}
users, err := GetAllUsers(db)
if err != nil {
return []WeeklyHours{}, err
}
rows, err := db.Query(`
SELECT user_id, date, start_time, end_time, type
FROM time_entries
WHERE date >= ? AND date <= ?
ORDER BY date DESC
`, schoolYear.StartDate, schoolYear.EndDate)
if err != nil {
return []WeeklyHours{}, err
}
defer rows.Close()
userTotals := make(map[int]float64)
for rows.Next() {
var userID int
var date, startTime, endTime, entryType string
if err := rows.Scan(&userID, &date, &startTime, &endTime, &entryType); err != nil {
continue
}
var hours float64
if entryType == "lesson" {
hours = 1.0
} else {
hours = calculateHoursDiff(startTime, endTime)
}
userTotals[userID] += hours
}
var result []WeeklyHours
for _, user := range users {
if !user.IsAdmin {
total := userTotals[user.ID]
remaining := user.YearlyHours - total
result = append(result, WeeklyHours{
UserID: user.ID,
Username: user.Username,
Year: 0,
Week: 0,
TotalHours: total,
YearlyTarget: user.YearlyHours,
YearlyActual: total,
RemainingYearly: remaining,
})
}
}
return result, nil
}

View file

@ -448,3 +448,51 @@ func (app *App) CreateUserHandler(c echo.Context) error {
return c.NoContent(http.StatusCreated)
}
func (app *App) GetSchoolYearsHandler(c echo.Context) error {
years, err := GetAllSchoolYears(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if years == nil {
years = []SchoolYear{}
}
return c.JSON(http.StatusOK, years)
}
func (app *App) CreateSchoolYearHandler(c echo.Context) error {
var req CreateSchoolYearRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusCreated)
}
func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID")
}
if err := SetActiveSchoolYear(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
year, err := GetActiveSchoolYear(app.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if year == nil {
return c.JSON(http.StatusOK, map[string]any{"active": false})
}
return c.JSON(http.StatusOK, year)
}

View file

@ -46,6 +46,7 @@ func main() {
protected.GET("/week-has-entries", app.CheckWeekHasEntries)
protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
protected.GET("/my-info", app.GetMyInfoHandler)
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
}
admin := e.Group("/api/admin")
@ -64,6 +65,9 @@ func main() {
admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler)
admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler)
admin.POST("/time-entry", app.AdminCreateTimeEntryHandler)
admin.GET("/school-years", app.GetSchoolYearsHandler)
admin.POST("/school-years", app.CreateSchoolYearHandler)
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
}
e.Static("/", "./static")

View file

@ -61,6 +61,21 @@ type CreateUserRequest struct {
YearlyHours float64 `json:"yearly_hours"`
}
type SchoolYear struct {
ID int `json:"id"`
Name string `json:"name"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
type CreateSchoolYearRequest struct {
Name string `json:"name" validate:"required"`
StartDate string `json:"start_date" validate:"required"`
EndDate string `json:"end_date" validate:"required"`
}
type UpdateUserRequest struct {
Username string `json:"username"`
YearlyHours float64 `json:"yearly_hours"`

View file

@ -93,6 +93,10 @@ type alias Model =
, isProcessing : Bool
, mobileMenuOpen : Bool
, adminManualEntryForm : AdminManualEntry
, schoolYears : List SchoolYear
, newSchoolYear : NewSchoolYear
, activeSchoolYear : Maybe SchoolYear
, editingSchoolYearId : Maybe Int
}
@ -106,6 +110,7 @@ type AdminTab
= ScheduleTab
| UsersTab
| TimeEntriesTab
| SchoolYearsTab
type alias Schedule =
@ -221,6 +226,22 @@ type alias AdminManualEntry =
}
type alias SchoolYear =
{ id : Int
, name : String
, startDate : String
, endDate : String
, isActive : Bool
}
type alias NewSchoolYear =
{ name : String
, startDate : String
, endDate : String
}
init : Flags -> ( Model, Cmd Msg )
init flags =
let
@ -273,6 +294,10 @@ init flags =
, isProcessing = False
, mobileMenuOpen = False
, adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson"
, schoolYears = []
, newSchoolYear = NewSchoolYear "" "" ""
, activeSchoolYear = Nothing
, editingSchoolYearId = Nothing
}
cmd =
@ -283,7 +308,7 @@ init flags =
, fetchSchedules (Just token)
, fetchYearlyHoursSummary token
, if flags.isAdmin then
Cmd.none
fetchSchoolYears token
else
fetchMyInfo token
@ -394,6 +419,19 @@ type Msg
| AdminTimeEntrySaved (Result Http.Error ())
| FetchMyInfo
| MyInfoReceived (Result Http.Error User)
| FetchSchoolYears
| SchoolYearsReceived (Result Http.Error (List SchoolYear))
| FetchActiveSchoolYear
| ActiveSchoolYearReceived (Result Http.Error SchoolYear)
| UpdateNewSchoolYearName String
| UpdateNewSchoolYearStart String
| UpdateNewSchoolYearEnd String
| CreateSchoolYear
| SchoolYearCreated (Result Http.Error ())
| ActivateSchoolYear Int
| SchoolYearActivated (Result Http.Error ())
| DeleteSchoolYear Int
| SchoolYearDeleted (Result Http.Error ())
update : Msg -> Model -> ( Model, Cmd Msg )
@ -732,6 +770,17 @@ update msg model =
Nothing ->
Cmd.none
SchoolYearsTab ->
case model.token of
Just token ->
Cmd.batch
[ fetchSchoolYears token
, fetchActiveSchoolYear token
]
Nothing ->
Cmd.none
_ ->
Cmd.none
in
@ -1452,6 +1501,145 @@ update msg model =
MyInfoReceived (Err _) ->
( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none )
FetchSchoolYears ->
case model.token of
Just token ->
( model, fetchSchoolYears token )
Nothing ->
( model, Cmd.none )
SchoolYearsReceived (Ok years) ->
( { model | schoolYears = years }, Cmd.none )
SchoolYearsReceived (Err _) ->
( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none )
FetchActiveSchoolYear ->
case model.token of
Just token ->
( model, fetchActiveSchoolYear token )
Nothing ->
( model, Cmd.none )
ActiveSchoolYearReceived (Ok year) ->
( { model | activeSchoolYear = Just year }, Cmd.none )
ActiveSchoolYearReceived (Err _) ->
( { model | activeSchoolYear = Nothing }, Cmd.none )
UpdateNewSchoolYearName name ->
let
old =
model.newSchoolYear
new =
{ old | name = name }
in
( { model | newSchoolYear = new }, Cmd.none )
UpdateNewSchoolYearStart date ->
let
old =
model.newSchoolYear
new =
{ old | startDate = date }
in
( { model | newSchoolYear = new }, Cmd.none )
UpdateNewSchoolYearEnd date ->
let
old =
model.newSchoolYear
new =
{ old | endDate = date }
in
( { model | newSchoolYear = new }, Cmd.none )
CreateSchoolYear ->
if
String.isEmpty model.newSchoolYear.name
|| String.isEmpty model.newSchoolYear.startDate
|| String.isEmpty model.newSchoolYear.endDate
then
( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none )
else
case model.token of
Just token ->
( { model | isProcessing = True }, createSchoolYear token model.newSchoolYear )
Nothing ->
( model, Cmd.none )
SchoolYearCreated (Ok _) ->
case model.token of
Just token ->
( { model
| newSchoolYear = NewSchoolYear "" "" ""
, error = Nothing
, isProcessing = False
}
, fetchSchoolYears token
)
Nothing ->
( model, Cmd.none )
SchoolYearCreated (Err _) ->
( { model
| error = Just "Fehler beim Erstellen des Schuljahres"
, isProcessing = False
}
, Cmd.none
)
ActivateSchoolYear id ->
case model.token of
Just token ->
( model, activateSchoolYear token id )
Nothing ->
( model, Cmd.none )
SchoolYearActivated (Ok _) ->
case model.token of
Just token ->
( { model | error = Nothing }
, Cmd.batch
[ fetchSchoolYears token
, fetchActiveSchoolYear token
]
)
Nothing ->
( model, Cmd.none )
SchoolYearActivated (Err _) ->
( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none )
DeleteSchoolYear id ->
case model.token of
Just token ->
( model, deleteSchoolYear token id )
Nothing ->
( model, Cmd.none )
SchoolYearDeleted (Ok _) ->
case model.token of
Just token ->
( { model | error = Nothing }, fetchSchoolYears token )
Nothing ->
( model, Cmd.none )
SchoolYearDeleted (Err _) ->
( { model | error = Just "Fehler beim Löschen" }, Cmd.none )
-- SUBSCRIPTIONS
@ -2115,6 +2303,8 @@ viewAdminDashboard model =
[ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ]
, li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ]
[ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ]
, li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ]
[ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ]
]
]
, case model.activeTab of
@ -2126,6 +2316,9 @@ viewAdminDashboard model =
TimeEntriesTab ->
viewTimeEntriesTab model
SchoolYearsTab ->
viewSchoolYearsTab model
]
]
]
@ -3344,6 +3537,159 @@ viewTimeEntryRowWithActions model entry =
]
viewSchoolYearsTab : Model -> Html Msg
viewSchoolYearsTab model =
div []
[ h2 [ class "title" ] [ text "Schuljahre verwalten" ]
, case model.activeSchoolYear of
Just schoolYear ->
div [ class "notification is-info is-light mb-4" ]
[ p [ class "has-text-weight-bold" ]
[ text ("Aktives Schuljahr: " ++ schoolYear.name) ]
, p [ class "is-size-7" ]
[ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ]
]
Nothing ->
div [ class "notification is-warning is-light mb-4" ]
[ text " Kein Schuljahr aktiv! Bitte eines aktivieren." ]
, viewSchoolYearForm model
, viewSchoolYearsList model
]
viewSchoolYearForm : Model -> Html Msg
viewSchoolYearForm model =
div [ class "box" ]
[ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ]
, div [ class "columns" ]
[ div [ class "column is-4" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "text"
, placeholder "2024/2025"
, value model.newSchoolYear.name
, onInput UpdateNewSchoolYearName
, disabled model.isProcessing
]
[]
]
]
]
, div [ class "column is-4" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Startdatum" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "date"
, value model.newSchoolYear.startDate
, onInput UpdateNewSchoolYearStart
, disabled model.isProcessing
]
[]
]
]
]
, div [ class "column is-4" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Enddatum" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "date"
, value model.newSchoolYear.endDate
, onInput UpdateNewSchoolYearEnd
, disabled model.isProcessing
]
[]
]
]
]
]
, div [ class "field" ]
[ div [ class "control" ]
[ button
[ class "button is-primary"
, onClick CreateSchoolYear
, disabled
(String.isEmpty model.newSchoolYear.name
|| String.isEmpty model.newSchoolYear.startDate
|| String.isEmpty model.newSchoolYear.endDate
|| model.isProcessing
)
]
[ if model.isProcessing then
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
else
text ""
, text " Schuljahr erstellen"
]
]
]
]
viewSchoolYearsList : Model -> Html Msg
viewSchoolYearsList model =
div [ class "box mt-4" ]
[ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ]
, if List.isEmpty model.schoolYears then
p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ]
else
table [ class "table is-fullwidth is-striped is-hoverable" ]
[ thead []
[ tr []
[ th [] [ text "Name" ]
, th [] [ text "Startdatum" ]
, th [] [ text "Enddatum" ]
, th [ class "has-text-centered" ] [ text "Status" ]
, th [ class "has-text-centered" ] [ text "Aktionen" ]
]
]
, tbody []
(List.map viewSchoolYearRow model.schoolYears)
]
]
viewSchoolYearRow : SchoolYear -> Html Msg
viewSchoolYearRow schoolYear =
tr []
[ td [] [ text schoolYear.name ]
, td [] [ text schoolYear.startDate ]
, td [] [ text schoolYear.endDate ]
, td [ class "has-text-centered" ]
[ if schoolYear.isActive then
span [ class "tag is-success" ] [ text "Aktiv" ]
else
span [ class "tag is-light" ] [ text "Inaktiv" ]
]
, td [ class "has-text-centered" ]
[ if not schoolYear.isActive then
button
[ class "button is-small is-info mr-2"
, onClick (ActivateSchoolYear schoolYear.id)
]
[ text "Aktivieren" ]
else
text ""
, button
[ class "button is-small is-danger"
, onClick (DeleteSchoolYear schoolYear.id)
]
[ text "Löschen" ]
]
]
-- HTTP
@ -3795,3 +4141,84 @@ fetchMyInfo token =
, timeout = Nothing
, tracker = Nothing
}
fetchSchoolYears : String -> Cmd Msg
fetchSchoolYears token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years"
, body = Http.emptyBody
, expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
, timeout = Nothing
, tracker = Nothing
}
fetchActiveSchoolYear : String -> Cmd Msg
fetchActiveSchoolYear token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/school-year/active"
, body = Http.emptyBody
, expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
, timeout = Nothing
, tracker = Nothing
}
createSchoolYear : String -> NewSchoolYear -> Cmd Msg
createSchoolYear token schoolYear =
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years"
, body =
Http.jsonBody <|
Encode.object
[ ( "name", Encode.string schoolYear.name )
, ( "start_date", Encode.string schoolYear.startDate )
, ( "end_date", Encode.string schoolYear.endDate )
]
, expect = Http.expectWhatever SchoolYearCreated
, timeout = Nothing
, tracker = Nothing
}
activateSchoolYear : String -> Int -> Cmd Msg
activateSchoolYear token id =
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
, body = Http.emptyBody
, expect = Http.expectWhatever SchoolYearActivated
, timeout = Nothing
, tracker = Nothing
}
deleteSchoolYear : String -> Int -> Cmd Msg
deleteSchoolYear token id =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years/" ++ String.fromInt id
, body = Http.emptyBody
, expect = Http.expectWhatever SchoolYearDeleted
, timeout = Nothing
, tracker = Nothing
}
schoolYearDecoder : Decoder SchoolYear
schoolYearDecoder =
Decode.map5 SchoolYear
(field "id" int)
(field "name" string)
(field "start_date" string)
(field "end_date" string)
(field "is_active" bool)