diff --git a/backend/database.go b/backend/database.go index 123adee..6897bd9 100644 --- a/backend/database.go +++ b/backend/database.go @@ -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 +} diff --git a/backend/handlers.go b/backend/handlers.go index cd5713c..e87b5f3 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -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) +} diff --git a/backend/main.go b/backend/main.go index f09a17a..bdf47b5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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") diff --git a/backend/models.go b/backend/models.go index 6ca8f71..1348146 100644 --- a/backend/models.go +++ b/backend/models.go @@ -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"` diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 746031f..4afe315 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -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)