From 55b36e5e62fd82129d38d8b7353793e8d73aecd5 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 23:22:49 +0100 Subject: [PATCH 1/2] fix: fix while deleting timeentries for whole week old entries have not been deleted, before new entries have been added. This has been fixed. Also manual entries by administrators are know protected and can only be deleted by an administrator. --- backend/database.go | 16 ++++++++++++++++ backend/handlers.go | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/database.go b/backend/database.go index 7987953..66f3e54 100644 --- a/backend/database.go +++ b/backend/database.go @@ -608,3 +608,19 @@ func DeleteSchoolYear(db *sql.DB, id int) error { return nil } + +func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error { + dates := calculateWeekDates(year, week) + var dateList []string + for day := 0; day <= 4; day++ { + dateList = append(dateList, dates.Dates[fmt.Sprint(day)]) + } + + query := `DELETE FROM time_entries + WHERE user_id = ? + AND type != 'manual' + AND date IN (?, ?, ?, ?, ?)` + + _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) + return err +} diff --git a/backend/handlers.go b/backend/handlers.go index 22d4e92..06b3f57 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -340,7 +340,7 @@ func (app *App) DeleteWeekEntries(c echo.Context) error { return HandleError(c, ErrInvalidInputMsg("Woche")) } - if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { return HandleError(c, ErrDatabaseMsg(err)) } @@ -417,6 +417,19 @@ func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) } + if len(req.Entries) > 0 { + firstDate := req.Entries[0].Date + t, err := time.Parse("2006-01-02", firstDate) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Datum-Format")) + } + year, week := t.ISOWeek() + + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + } + tx, err := app.DB.Begin() if err != nil { return HandleError(c, ErrDatabaseMsg(err)) From ccae467ceb8dfd59c295e58dd3c1dd34b30d7a0b Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 23:24:32 +0100 Subject: [PATCH 2/2] docs: update Readme --- README.md | 20 +- frontend/src/Api/Auth.elm | 21 + frontend/src/Api/Decoders.elm | 109 + frontend/src/Api/Schedule.elm | 120 + frontend/src/Api/SchoolYear.elm | 85 + frontend/src/Api/TimeEntry.elm | 201 + frontend/src/Api/User.elm | 110 + frontend/src/Main.elm | 4400 +------------------ frontend/src/Types/Api.elm | 17 + frontend/src/Types/Model.elm | 218 + frontend/src/Types/Msg.elm | 133 + frontend/src/Types/Page.elm | 17 + frontend/src/Update/AuthUpdate.elm | 115 + frontend/src/Update/ScheduleUpdate.elm | 244 + frontend/src/Update/SchoolYearUpdate.elm | 139 + frontend/src/Update/TimeEntryUpdate.elm | 189 + frontend/src/Update/Update.elm | 811 ++++ frontend/src/Update/UserUpdate.elm | 196 + frontend/src/Utils/DateUtils.elm | 338 ++ frontend/src/Utils/ErrorHandler.elm | 42 + frontend/src/Utils/Ports.elm | 20 + frontend/src/Utils/TimeUtils.elm | 34 + frontend/src/View/AdminDashboard.elm | 1165 +++++ frontend/src/View/Components/Navigation.elm | 99 + frontend/src/View/Components/Schedule.elm | 76 + frontend/src/View/Components/Toast.elm | 66 + frontend/src/View/Login.elm | 57 + frontend/src/View/UserDashboard.elm | 338 ++ frontend/src/View/View.elm | 29 + 29 files changed, 5012 insertions(+), 4397 deletions(-) create mode 100644 frontend/src/Api/Auth.elm create mode 100644 frontend/src/Api/Decoders.elm create mode 100644 frontend/src/Api/Schedule.elm create mode 100644 frontend/src/Api/SchoolYear.elm create mode 100644 frontend/src/Api/TimeEntry.elm create mode 100644 frontend/src/Api/User.elm create mode 100644 frontend/src/Types/Api.elm create mode 100644 frontend/src/Types/Model.elm create mode 100644 frontend/src/Types/Msg.elm create mode 100644 frontend/src/Types/Page.elm create mode 100644 frontend/src/Update/AuthUpdate.elm create mode 100644 frontend/src/Update/ScheduleUpdate.elm create mode 100644 frontend/src/Update/SchoolYearUpdate.elm create mode 100644 frontend/src/Update/TimeEntryUpdate.elm create mode 100644 frontend/src/Update/Update.elm create mode 100644 frontend/src/Update/UserUpdate.elm create mode 100644 frontend/src/Utils/DateUtils.elm create mode 100644 frontend/src/Utils/ErrorHandler.elm create mode 100644 frontend/src/Utils/Ports.elm create mode 100644 frontend/src/Utils/TimeUtils.elm create mode 100644 frontend/src/View/AdminDashboard.elm create mode 100644 frontend/src/View/Components/Navigation.elm create mode 100644 frontend/src/View/Components/Schedule.elm create mode 100644 frontend/src/View/Components/Toast.elm create mode 100644 frontend/src/View/Login.elm create mode 100644 frontend/src/View/UserDashboard.elm create mode 100644 frontend/src/View/View.elm diff --git a/README.md b/README.md index 4922c95..732cdbb 100644 --- a/README.md +++ b/README.md @@ -179,15 +179,15 @@ export JWT_SECRET=development-secret ### Umgebungsvariablen -| Variable | Beschreibung | Standard | Erforderlich | -| ------------------------ | ------------------------------------------------ | --------------------------------- | ------------ | -| `PORT` | HTTP-Server Port | `8080` | Nein | -| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | -| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | -| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | -| `TZ` | Zeitzone | `Europe/Berlin` | Nein | -| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | -| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | +| Variable | Beschreibung | Standard | Erforderlich | +| ------------------------ | ------------------------------------------------- | ----------------------------------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | +| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | ### Docker-Volumes @@ -770,6 +770,6 @@ Todo --- -**Version**: 1.1.0 +**Version**: 1.5.0 **Letztes Update**: November 2025 **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm new file mode 100644 index 0000000..0de5c4e --- /dev/null +++ b/frontend/src/Api/Auth.elm @@ -0,0 +1,21 @@ +module Api.Auth exposing (loginRequest) + +import Api.Decoders exposing (loginDecoder) +import Http +import Json.Encode as Encode +import Types.Api exposing (LoginResult) +import Types.Msg exposing (Msg(..)) + + +loginRequest : String -> String -> Cmd Msg +loginRequest username password = + Http.post + { url = "/api/login" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string username ) + , ( "password", Encode.string password ) + ] + , expect = Http.expectJson LoginResponse loginDecoder + } diff --git a/frontend/src/Api/Decoders.elm b/frontend/src/Api/Decoders.elm new file mode 100644 index 0000000..cb72efa --- /dev/null +++ b/frontend/src/Api/Decoders.elm @@ -0,0 +1,109 @@ +module Api.Decoders exposing + ( apiErrorDecoder + , loginDecoder + , scheduleDecoder + , schoolYearDecoder + , timeEntryDecoder + , userDecoder + , weekDatesDecoder + , weeklyHoursDecoder + , yearlyHoursSummaryDecoder + ) + +import Dict +import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) +import Types.Api exposing (ApiError, LoginResult) +import Types.Model exposing (..) + + +loginDecoder : Decoder LoginResult +loginDecoder = + Decode.map3 LoginResult + (field "token" string) + (field "username" string) + (field "is_admin" bool) + + +scheduleDecoder : Decoder Schedule +scheduleDecoder = + Decode.map6 Schedule + (field "id" int) + (field "day_of_week" int) + (field "start_time" string) + (field "end_time" string) + (field "type" string) + (field "title" string) + + +timeEntryDecoder : Decoder TimeEntry +timeEntryDecoder = + Decode.map8 TimeEntry + (field "id" int) + (field "user_id" int) + (field "schedule_id" int) + (field "date" string) + (field "type" string) + (field "username" string) + (field "start_time" string) + (field "end_time" string) + + +userDecoder : Decoder User +userDecoder = + Decode.map4 User + (field "id" int) + (field "username" string) + (field "is_admin" bool) + (field "yearly_hours" float) + + +weekDatesDecoder : Decoder WeekDates +weekDatesDecoder = + Decode.map4 WeekDates + (field "year" int) + (field "week" int) + (field "dates" (Decode.dict string) |> Decode.map Dict.toList) + (field "range" string) + + +weeklyHoursDecoder : Decoder WeeklyHours +weeklyHoursDecoder = + Decode.map7 WeeklyHours + (field "user_id" int) + (field "username" string) + (field "year" int) + (field "week" int) + (field "total_hours" float) + (field "expected_hours" float) + (field "remaining_hours" float) + + +yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary +yearlyHoursSummaryDecoder = + Decode.succeed YearlyHoursSummary + |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) + |> Decode.andThen (\f -> Decode.map f (field "username" string)) + |> Decode.andThen (\f -> Decode.map f (field "year" int)) + |> Decode.andThen (\f -> Decode.map f (field "week" int)) + |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) + |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) + + +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) + + +apiErrorDecoder : Decoder ApiError +apiErrorDecoder = + Decode.map2 ApiError + (field "code" string) + (field "message" string) diff --git a/frontend/src/Api/Schedule.elm b/frontend/src/Api/Schedule.elm new file mode 100644 index 0000000..f966645 --- /dev/null +++ b/frontend/src/Api/Schedule.elm @@ -0,0 +1,120 @@ +module Api.Schedule exposing + ( createSchedule + , deleteSchedule + , fetchSchedules + , saveTimeEntriesForWeek + ) + +import Api.Decoders exposing (scheduleDecoder) +import Http +import Json.Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates) +import Types.Msg exposing (Msg(..)) + + +fetchSchedules : Maybe String -> Cmd Msg +fetchSchedules maybeToken = + case maybeToken of + Just token -> + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/schedules" + , body = Http.emptyBody + , expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder) + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +createSchedule : String -> NewSchedule -> Cmd Msg +createSchedule token schedule = + case String.toInt schedule.dayOfWeek of + Just day -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules" + , body = + Http.jsonBody <| + Encode.object + [ ( "day_of_week", Encode.int day ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "title", Encode.string schedule.title ) + ] + , expect = Http.expectWhatever ScheduleCreated + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +deleteSchedule : String -> Int -> Cmd Msg +deleteSchedule token scheduleId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId + , body = Http.emptyBody + , expect = Http.expectWhatever ScheduleDeleted + , timeout = Nothing + , tracker = Nothing + } + + +saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg +saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = + case maybeWeekDates of + Nothing -> + Cmd.none + + Just weekDates -> + let + getScheduleById id = + List.filter (\s -> s.id == id) schedules |> List.head + + getDateForDay dayOfWeek = + weekDates.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + + createEntryData entry = + case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of + ( Just schedule, Just dateStr ) -> + Just <| + Encode.object + [ ( "schedule_id", Encode.int entry.scheduleId ) + , ( "date", Encode.string dateStr ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + ] + + _ -> + Nothing + + entriesData = + List.filterMap createEntryData selectedEntries + in + if List.isEmpty entriesData then + Cmd.none + + else + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries/batch" + , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/SchoolYear.elm b/frontend/src/Api/SchoolYear.elm new file mode 100644 index 0000000..be1fb63 --- /dev/null +++ b/frontend/src/Api/SchoolYear.elm @@ -0,0 +1,85 @@ +module Api.SchoolYear exposing + ( activateSchoolYear + , createSchoolYear + , deleteSchoolYear + , fetchActiveSchoolYear + , fetchSchoolYears + ) + +import Api.Decoders exposing (schoolYearDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchoolYear) +import Types.Msg exposing (Msg(..)) + + +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 + } diff --git a/frontend/src/Api/TimeEntry.elm b/frontend/src/Api/TimeEntry.elm new file mode 100644 index 0000000..c1ebede --- /dev/null +++ b/frontend/src/Api/TimeEntry.elm @@ -0,0 +1,201 @@ +module Api.TimeEntry exposing + ( checkWeekHasEntries + , createAdminTimeEntry + , deleteTimeEntry + , deleteWeekEntries + , downloadYearlySummaryPDF + , fetchAllTimeEntries + , fetchMyTimeEntries + , fetchWeekDates + , fetchWeeklyHours + , fetchYearlyHoursSummary + , updateTimeEntry + ) + +import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder) +import Bytes exposing (Bytes) +import Http +import Json.Decode as Decode exposing (bool, field) +import Json.Encode as Encode +import Types.Model exposing (AdminManualEntry, EditingTimeEntry) +import Types.Msg exposing (Msg(..)) + + +fetchMyTimeEntries : String -> Cmd Msg +fetchMyTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries" + , body = Http.emptyBody + , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchAllTimeEntries : String -> Cmd Msg +fetchAllTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries" + , body = Http.emptyBody + , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeekDates : String -> Int -> Int -> Cmd Msg +fetchWeekDates token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekDatesReceived weekDatesDecoder + , timeout = Nothing + , tracker = Nothing + } + + +checkWeekHasEntries : String -> Int -> Int -> Cmd Msg +checkWeekHasEntries token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) + , timeout = Nothing + , tracker = Nothing + } + + +deleteWeekEntries : String -> Int -> Int -> Cmd Msg +deleteWeekEntries token year week = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectWhatever WeekEntriesDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg +updateTimeEntry token entry = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId + , body = + Http.jsonBody <| + Encode.object + [ ( "date", Encode.string entry.date ) + , ( "start_time", Encode.string entry.startTime ) + , ( "end_time", Encode.string entry.endTime ) + , ( "type", Encode.string entry.entryType ) + ] + , expect = Http.expectWhatever TimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + +deleteTimeEntry : String -> Int -> Cmd Msg +deleteTimeEntry token entryId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entryId + , body = Http.emptyBody + , expect = Http.expectWhatever TimeEntryDeleted + , timeout = Nothing + , tracker = Nothing + } + + +createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg +createAdminTimeEntry token entry = + case entry.selectedUserId of + Just userId -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entry" + , body = + Http.jsonBody <| + Encode.object + [ ( "user_id", Encode.int userId ) + , ( "date", Encode.string entry.date ) + , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) + , ( "type", Encode.string "manual" ) + ] + , expect = Http.expectWhatever AdminTimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +fetchYearlyHoursSummary : String -> Cmd Msg +fetchYearlyHoursSummary token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/yearly-hours-summary" + , body = Http.emptyBody + , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +downloadYearlySummaryPDF : String -> Cmd Msg +downloadYearlySummaryPDF token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/yearly-summary/pdf" + , body = Http.emptyBody + , expect = + Http.expectBytesResponse YearlySummaryPDFReceived + (\response -> + case response of + Http.GoodStatus_ _ body -> + Ok body + + Http.BadUrl_ url -> + Err (Http.BadUrl url) + + Http.Timeout_ -> + Err Http.Timeout + + Http.NetworkError_ -> + Err Http.NetworkError + + Http.BadStatus_ metadata _ -> + Err (Http.BadStatus metadata.statusCode) + ) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeeklyHours : String -> Cmd Msg +fetchWeeklyHours token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/weekly-hours" + , body = Http.emptyBody + , expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder) + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/User.elm b/frontend/src/Api/User.elm new file mode 100644 index 0000000..17c77ac --- /dev/null +++ b/frontend/src/Api/User.elm @@ -0,0 +1,110 @@ +module Api.User exposing + ( createUser + , deleteUser + , fetchMyInfo + , fetchUsers + , resetUserPassword + , updateUserWorkHours + ) + +import Api.Decoders exposing (userDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewUser) +import Types.Msg exposing (Msg(..)) + + +fetchUsers : String -> Cmd Msg +fetchUsers token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/list" + , body = Http.emptyBody + , expect = Http.expectJson UsersReceived (Decode.list userDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchMyInfo : String -> Cmd Msg +fetchMyInfo token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-info" + , body = Http.emptyBody + , expect = Http.expectJson MyInfoReceived userDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createUser : String -> NewUser -> Cmd Msg +createUser token user = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string user.username ) + , ( "password", Encode.string user.password ) + , ( "is_admin", Encode.bool user.isAdmin ) + ] + , expect = Http.expectWhatever UserCreated + , timeout = Nothing + , tracker = Nothing + } + + +deleteUser : String -> Int -> Cmd Msg +deleteUser token userId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/delete?id=" ++ String.fromInt userId + , body = Http.emptyBody + , expect = Http.expectWhatever UserDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateUserWorkHours : String -> Int -> String -> Cmd Msg +updateUserWorkHours token userId hours = + case String.toFloat hours of + Just workHours -> + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId + , body = + Http.jsonBody <| + Encode.object + [ ( "yearly_hours", Encode.float workHours ) ] + , expect = Http.expectWhatever UserWorkHoursSaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +resetUserPassword : String -> Int -> String -> Cmd Msg +resetUserPassword token userId newPassword = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" + , body = + Http.jsonBody <| + Encode.object + [ ( "new_password", Encode.string newPassword ) ] + , expect = Http.expectWhatever ResetPasswordSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 710b286..6f29eab 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,34 +1,20 @@ -port module Main exposing (..) +module Main exposing (..) +import Api.Auth exposing (..) +import Api.Decoders exposing (..) +import Api.Schedule exposing (..) +import Api.SchoolYear exposing (..) +import Api.TimeEntry exposing (..) +import Api.User exposing (..) import Browser -import Bytes exposing (Bytes) -import Dict exposing (Dict) -import File.Download -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Http -import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) -import Json.Encode as Encode -import Process import Task import Time - - - --- PORTS - - -port saveToken : Encode.Value -> Cmd msg - - -port removeToken : () -> Cmd msg - - -port confirmDelete : String -> Cmd msg - - -port confirmDeleteResponse : (Bool -> msg) -> Sub msg +import Types.Model exposing (..) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (..) +import Update.Update exposing (update) +import Utils.Ports exposing (..) +import View.View exposing (view) @@ -45,222 +31,6 @@ main = } - --- FLAGS - - -type alias Flags = - { token : Maybe String - , isAdmin : Bool - } - - - --- MODEL - - -type alias Model = - { page : Page - , activeTab : AdminTab - , username : String - , password : String - , token : Maybe String - , isAdmin : Bool - , schedules : List Schedule - , users : List User - , timeEntries : List TimeEntry - , weeklyHours : List WeeklyHours - , yearlyHoursSummary : List YearlyHoursSummary - , selectedEntries : List SelectedEntry - , currentWeek : Int - , currentYear : Int - , weekDates : Maybe WeekDates - , currentTime : Time.Posix - , zone : Time.Zone - , newSchedule : NewSchedule - , newUser : NewUser - , error : Maybe String - , weekEditMode : Bool - , hasEntriesForCurrentWeek : Bool - , userWeeklySummary : Maybe WeeklySummary - , editingTimeEntryId : Maybe Int - , editingTimeEntry : EditingTimeEntry - , editingUserId : Maybe Int - , editingUserWorkHours : String - , resetPasswordUserId : Maybe Int - , resetPasswordNew : String - , pendingDeleteId : Maybe Int - , selectedUserId : Maybe Int - , userWorkHoursInput : String - , userPasswordInput : String - , isProcessing : Bool - , mobileMenuOpen : Bool - , adminManualEntryForm : AdminManualEntry - , schoolYears : List SchoolYear - , newSchoolYear : NewSchoolYear - , activeSchoolYear : Maybe SchoolYear - , editingSchoolYearId : Maybe Int - , toasts : List Toast - , nextToastId : Int - } - - -type ToastType - = ErrorToast - | SuccessToast - | InfoToast - | WarningToast - - -type alias Toast = - { id : Int - , message : String - , toastType : ToastType - , dismissible : Bool - } - - -type Page - = LoginPage - | UserDashboard - | AdminDashboard - - -type AdminTab - = ScheduleTab - | UsersTab - | TimeEntriesTab - | SchoolYearsTab - - -type alias Schedule = - { id : Int - , dayOfWeek : Int - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias User = - { id : Int - , username : String - , isAdmin : Bool - , yearlyWorkHours : Float - } - - -type alias TimeEntry = - { id : Int - , userId : Int - , scheduleId : Int - , date : String - , entryType : String - , username : String - , startTime : String - , endTime : String - } - - -type alias SelectedEntry = - { scheduleId : Int - , dayOfWeek : Int - } - - -type alias NewSchedule = - { dayOfWeek : String - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias NewUser = - { username : String - , password : String - , isAdmin : Bool - } - - -type alias WeekDates = - { year : Int - , week : Int - , dates : List ( String, String ) - , range : String - } - - -type alias WeeklySummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias EditingTimeEntry = - { entryId : Int - , date : String - , startTime : String - , endTime : String - , entryType : String - } - - -type alias WeeklyHours = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias YearlyHoursSummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , yearlyTarget : Float - , yearlyActual : Float - , weeklyTarget : Float - , remainingYearly : Float - } - - -type alias AdminManualEntry = - { selectedUserId : Maybe Int - , date : String - , hours : String - , entryType : String - } - - -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 @@ -346,4153 +116,9 @@ init flags = --- UPDATE - - -type Msg - = UpdateUsername String - | UpdatePassword String - | Login - | LoginResponse (Result Http.Error LoginResult) - | Logout - | SetTime Time.Posix - | FetchSchedules - | SchedulesReceived (Result Http.Error (List Schedule)) - | ToggleScheduleSelection Int Int - | SaveTimeEntries - | TimeEntriesSaved (Result Http.Error ()) - | PreviousWeek - | NextWeek - | EnableEditMode - | DisableEditMode - | DeleteWeekEntries - | WeekEntriesDeleted (Result Http.Error ()) - | SwitchTab AdminTab - | UpdateNewScheduleDay String - | UpdateNewScheduleStart String - | UpdateNewScheduleEnd String - | UpdateNewScheduleType String - | UpdateNewScheduleTitle String - | CreateSchedule - | ScheduleCreated (Result Http.Error ()) - | DeleteSchedule Int - | ScheduleDeleted (Result Http.Error ()) - | UpdateNewUsername String - | UpdateNewPassword String - | UpdateNewUserAdmin Bool - | CreateUser - | UserCreated (Result Http.Error ()) - | DeleteUser Int - | UserDeleted (Result Http.Error ()) - | FetchUsers - | UsersReceived (Result Http.Error (List User)) - | FetchMyTimeEntries - | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchAllTimeEntries - | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchWeeklyHours - | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) - | FetchYearlyHoursSummary - | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) - | FetchWeekDates - | WeekDatesReceived (Result Http.Error WeekDates) - | CheckWeekHasEntries - | WeekHasEntriesReceived (Result Http.Error Bool) - | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) - | EditTimeEntry Int - | CancelEditTimeEntry - | UpdateEditTimeEntryDate String - | UpdateEditTimeEntryStartTime String - | UpdateEditTimeEntryEndTime String - | UpdateEditTimeEntryType String - | SaveEditTimeEntry - | TimeEntrySaved (Result Http.Error ()) - | TimeEntryDeleted (Result Http.Error ()) - | EditUserWorkHours Int - | CancelEditUserWorkHours - | UpdateEditUserWorkHours String - | SaveUserWorkHours - | UserWorkHoursSaved (Result Http.Error ()) - | ResetUserPassword Int - | CancelResetPassword - | UpdateResetPasswordNew String - | SaveResetPassword - | ResetPasswordSaved (Result Http.Error ()) - | ConfirmDeleteTimeEntry Int - | ConfirmDeleteUser Int - | DeleteConfirmed Bool - | StartEditingTimeEntry Int TimeEntry - | CancelEditingTimeEntry - | UpdateEditingTimeEntryDate String - | UpdateEditingTimeEntryStartTime String - | UpdateEditingTimeEntryEndTime String - | UpdateEditingTimeEntryType String - | SaveEditingTimeEntry - | SelectUserForManagement Int - | UpdateUserWorkHours String - | UpdateUserPassword String - | SaveUserPassword - | UserPasswordSaved (Result Http.Error ()) - | ToggleMobileMenu - | CloseMobileMenu - | SelectUserForManualEntry Int - | UpdateManualEntryDate String - | UpdateManualEntryHours String - | UpdateManualEntryType String - | SaveAdminTimeEntry - | 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 ()) - | DownloadYearlySummaryPDF - | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) - | ShowToast String ToastType - | DismissToast Int - | AutoDismissToast Int - - -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = - case msg of - ToggleMobileMenu -> - ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) - - CloseMobileMenu -> - ( { model | mobileMenuOpen = False }, Cmd.none ) - - UpdateUsername username -> - ( { model | username = username }, Cmd.none ) - - UpdatePassword password -> - ( { model | password = password }, Cmd.none ) - - Login -> - if model.isProcessing then - ( model, Cmd.none ) - - else - ( { model | isProcessing = True }, loginRequest model.username model.password ) - - LoginResponse (Ok result) -> - let - newPage = - if result.isAdmin then - AdminDashboard - - else - UserDashboard - - ( year, week ) = - getISOWeekFromPosix model.currentTime - - tokenData = - Encode.object - [ ( "token", Encode.string result.token ) - , ( "isAdmin", Encode.bool result.isAdmin ) - ] - in - ( { model - | token = Just result.token - , username = result.username - , isAdmin = result.isAdmin - , page = newPage - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ saveToken tokenData - , fetchSchedules (Just result.token) - , Task.perform (\_ -> ShowToast ("Willkommen, " ++ result.username ++ "!") SuccessToast) (Task.succeed ()) - , if not result.isAdmin then - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchYearlyHoursSummary result.token - , fetchMyInfo result.token - ] - - else - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchYearlyHoursSummary result.token - ] - ] - ) - - LoginResponse (Err err) -> - let - errorMsg = - case err of - Http.BadStatus 401 -> - "Benutzername oder Passwort ungültig" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Anmeldung fehlgeschlagen" - in - ( { model | isProcessing = False } - , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) - ) - - Logout -> - ( { model - | page = LoginPage - , token = Nothing - , isAdmin = False - , username = "" - , password = "" - , isProcessing = False - } - , removeToken () - ) - - FetchSchedules -> - ( model, fetchSchedules model.token ) - - SchedulesReceived (Ok schedules) -> - ( { model | schedules = schedules }, Cmd.none ) - - SchedulesReceived (Err err) -> - ( model, handleApiError err ) - - ToggleScheduleSelection scheduleId dayOfWeek -> - let - entry = - { scheduleId = scheduleId, dayOfWeek = dayOfWeek } - - newSelected = - if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then - List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries - - else - entry :: model.selectedEntries - in - ( { model | selectedEntries = newSelected }, Cmd.none ) - - SaveTimeEntries -> - case model.token of - Just token -> - ( { model | error = Nothing } - , saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntriesSaved (Ok _) -> - case model.token of - Just token -> - ( { model - | error = Nothing - , weekEditMode = False - , hasEntriesForCurrentWeek = True - } - , Cmd.batch - [ fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntriesSaved (Err err) -> - ( model, handleApiError err ) - - PreviousWeek -> - let - ( newYear, newWeek ) = - previousWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - NextWeek -> - let - ( newYear, newWeek ) = - nextWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - FetchWeekDates -> - case model.token of - Just token -> - ( model, fetchWeekDates token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekDatesReceived (Ok weekDates) -> - ( { model | weekDates = Just weekDates }, Cmd.none ) - - WeekDatesReceived (Err err) -> - ( model, handleApiError err ) - - CheckWeekHasEntries -> - case model.token of - Just token -> - ( model, checkWeekHasEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekHasEntriesReceived (Ok hasEntries) -> - ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) - - WeekHasEntriesReceived (Err err) -> - ( model, handleApiError err ) - - SetTime time -> - let - ( year, week ) = - getISOWeekFromPosix time - - cmds = - case model.token of - Just token -> - if model.page == UserDashboard || model.page == LoginPage then - Cmd.batch - [ checkWeekHasEntries token year week - , fetchWeekDates token year week - , fetchMyTimeEntries token - ] - - else - Cmd.none - - Nothing -> - Cmd.none - in - ( { model - | currentTime = time - , currentWeek = week - , currentYear = year - } - , cmds - ) - - EnableEditMode -> - let - currentWeekEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - - preSelectedEntries = - List.map - (\entry -> - let - parts = - String.split "-" entry.date - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - dayOfWeek = - getDayOfWeek year month day - in - { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } - ) - currentWeekEntries - in - ( { model - | weekEditMode = True - , selectedEntries = preSelectedEntries - } - , Cmd.none - ) - - DisableEditMode -> - ( { model - | weekEditMode = False - } - , Cmd.none - ) - - DeleteWeekEntries -> - case model.token of - Just token -> - ( model, deleteWeekEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekEntriesDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | weekEditMode = True - , selectedEntries = [] - , hasEntriesForCurrentWeek = False - } - , Cmd.batch - [ fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - WeekEntriesDeleted (Err err) -> - ( model, handleApiError err ) - - SwitchTab tab -> - let - cmd = - case tab of - UsersTab -> - case model.token of - Just token -> - fetchUsers token - - Nothing -> - Cmd.none - - TimeEntriesTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - ] - - Nothing -> - Cmd.none - - SchoolYearsTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchSchoolYears token - , fetchActiveSchoolYear token - ] - - Nothing -> - Cmd.none - - _ -> - Cmd.none - in - ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) - - UpdateNewScheduleDay day -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | dayOfWeek = day } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleStart time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | startTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleEnd time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | endTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleType scheduleType -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | scheduleType = scheduleType } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleTitle title -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | title = title } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - CreateSchedule -> - if - String.isEmpty model.newSchedule.dayOfWeek - || String.isEmpty model.newSchedule.startTime - || String.isEmpty model.newSchedule.endTime - then - ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - else - case model.token of - Just token -> - ( { model | isProcessing = True }, createSchedule token model.newSchedule ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleCreated (Ok _) -> - case model.token of - Just token -> - let - emptySchedule = - NewSchedule "" "" "" "lesson" "" - in - ( { model - | newSchedule = emptySchedule - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ fetchSchedules model.token - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleCreated (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - DeleteSchedule scheduleId -> - case model.token of - Just token -> - ( model, deleteSchedule token scheduleId ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleDeleted (Ok _) -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ fetchSchedules (Just token) - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleDeleted (Err err) -> - ( model, handleApiError err ) - - UpdateNewUsername username -> - let - oldUser = - model.newUser - - newUser = - { oldUser | username = username } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewPassword password -> - let - oldUser = - model.newUser - - newUser = - { oldUser | password = password } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewUserAdmin isAdmin -> - let - oldUser = - model.newUser - - newUser = - { oldUser | isAdmin = isAdmin } - in - ( { model | newUser = newUser }, Cmd.none ) - - CreateUser -> - case model.token of - Just token -> - ( model, createUser token model.newUser ) - - Nothing -> - ( model, Cmd.none ) - - UserCreated (Ok _) -> - let - emptyUser = - NewUser "" "" False - in - case model.token of - Just token -> - ( { model | newUser = emptyUser } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserCreated (Err err) -> - ( model, handleApiError err ) - - DeleteUser userId -> - case model.token of - Just token -> - ( model, deleteUser token userId ) - - Nothing -> - ( model, Cmd.none ) - - UserDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | pendingDeleteId = Nothing - , error = Nothing - , editingUserId = Nothing - , resetPasswordUserId = Nothing - } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserDeleted (Err err) -> - ( { model | pendingDeleteId = Nothing }, handleApiError err ) - - FetchUsers -> - case model.token of - Just token -> - ( model, fetchUsers token ) - - Nothing -> - ( model, Cmd.none ) - - UsersReceived (Ok users) -> - ( { model | users = users }, Cmd.none ) - - UsersReceived (Err err) -> - ( model, handleApiError err ) - - FetchMyTimeEntries -> - case model.token of - Just token -> - ( model, fetchMyTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - MyTimeEntriesReceived (Ok entries) -> - let - hasEntries = - List.any - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - entries - in - ( { model - | timeEntries = entries - , hasEntriesForCurrentWeek = hasEntries - , weekEditMode = False - } - , Cmd.none - ) - - MyTimeEntriesReceived (Err err) -> - ( model, handleApiError err ) - - FetchAllTimeEntries -> - case model.token of - Just token -> - ( model, fetchAllTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - AllTimeEntriesReceived (Ok entries) -> - ( { model | timeEntries = entries }, Cmd.none ) - - AllTimeEntriesReceived (Err err) -> - ( model, handleApiError err ) - - FetchWeeklyHours -> - case model.token of - Just token -> - ( model, fetchWeeklyHours token ) - - Nothing -> - ( model, Cmd.none ) - - WeeklyHoursReceived (Ok hours) -> - ( { model | weeklyHours = hours }, Cmd.none ) - - WeeklyHoursReceived (Err err) -> - ( model, handleApiError err ) - - FetchYearlyHoursSummary -> - case model.token of - Just token -> - ( model, fetchYearlyHoursSummary token ) - - Nothing -> - ( model, Cmd.none ) - - YearlyHoursSummaryReceived (Ok summary) -> - ( { model | yearlyHoursSummary = summary }, Cmd.none ) - - YearlyHoursSummaryReceived (Err err) -> - ( model, handleApiError err ) - - MyWeeklySummaryReceived (Ok summary) -> - ( { model | userWeeklySummary = Just summary }, Cmd.none ) - - MyWeeklySummaryReceived (Err _) -> - ( { model | userWeeklySummary = Nothing }, Cmd.none ) - - EditTimeEntry entryId -> - case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of - Just entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = - { entryId = entryId - , date = entry.date - , startTime = entry.startTime - , endTime = entry.endTime - , entryType = entry.entryType - } - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - CancelEditTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - } - , Cmd.none - ) - - UpdateEditTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditTimeEntry -> - case model.token of - Just token -> - ( model, updateTimeEntry token model.editingTimeEntry ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntryDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntryDeleted (Err err) -> - ( { model | pendingDeleteId = Nothing }, handleApiError err ) - - EditUserWorkHours userId -> - case List.filter (\u -> u.id == userId) model.users |> List.head of - Just user -> - ( { model - | editingUserId = Just userId - , editingUserWorkHours = String.fromFloat user.yearlyWorkHours - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - CancelEditUserWorkHours -> - ( { model - | editingUserId = Nothing - , editingUserWorkHours = "" - } - , Cmd.none - ) - - UpdateEditUserWorkHours hours -> - ( { model | editingUserWorkHours = hours }, Cmd.none ) - - ResetUserPassword userId -> - ( { model - | resetPasswordUserId = Just userId - , resetPasswordNew = "" - } - , Cmd.none - ) - - CancelResetPassword -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - } - , Cmd.none - ) - - UpdateResetPasswordNew password -> - ( { model | resetPasswordNew = password }, Cmd.none ) - - SaveResetPassword -> - case model.resetPasswordUserId of - Just userId -> - case model.token of - Just token -> - ( model, resetUserPassword token userId model.resetPasswordNew ) - - Nothing -> - ( model, Cmd.none ) - - Nothing -> - ( model, Cmd.none ) - - ResetPasswordSaved (Ok _) -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - , error = Nothing - } - , Cmd.batch - [ case model.token of - Just token -> - fetchUsers token - - Nothing -> - Cmd.none - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) - ] - ) - - ResetPasswordSaved (Err err) -> - ( model, handleApiError err ) - - StartEditingTimeEntry entryId entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType - } - , Cmd.none - ) - - CancelEditingTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - } - , Cmd.none - ) - - UpdateEditingTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditingTimeEntry -> - case ( model.token, model.editingTimeEntryId ) of - ( Just token, Just entryId ) -> - ( model, updateTimeEntry token model.editingTimeEntry ) - - _ -> - ( model, Cmd.none ) - - TimeEntrySaved (Ok _) -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchAllTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntrySaved (Err err) -> - ( model, handleApiError err ) - - ConfirmDeleteTimeEntry entryId -> - ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) - - ConfirmDeleteUser userId -> - ( { model | pendingDeleteId = Just userId }, confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) - - DeleteConfirmed confirmed -> - if confirmed then - case ( model.token, model.pendingDeleteId ) of - ( Just token, Just id ) -> - let - isTimeEntry = - List.any (\e -> e.id == id) model.timeEntries - in - if isTimeEntry then - ( model, deleteTimeEntry token id ) - - else - ( model, deleteUser token id ) - - _ -> - ( model, Cmd.none ) - - else - ( { model | pendingDeleteId = Nothing }, Cmd.none ) - - SelectUserForManagement userId -> - ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) - - UpdateUserWorkHours input -> - ( { model | userWorkHoursInput = input }, Cmd.none ) - - SaveUserWorkHours -> - case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of - ( Just token, Just userId, Just hours ) -> - ( model, updateUserWorkHours token userId (String.fromFloat hours) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) - - UserWorkHoursSaved (Ok _) -> - case model.token of - Just token -> - ( { model - | editingUserWorkHours = "" - , editingUserId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserWorkHoursSaved (Err err) -> - ( model, handleApiError err ) - - UpdateUserPassword input -> - ( { model | userPasswordInput = input }, Cmd.none ) - - SaveUserPassword -> - case ( model.token, model.selectedUserId ) of - ( Just token, Just userId ) -> - if String.length model.userPasswordInput > 0 then - ( model, resetUserPassword token userId model.userPasswordInput ) - - else - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - UserPasswordSaved (Ok _) -> - ( { model - | userPasswordInput = "" - , selectedUserId = Nothing - , error = Nothing - } - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) - ) - - UserPasswordSaved (Err err) -> - ( model, handleApiError err ) - - SelectUserForManualEntry userId -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) - - UpdateManualEntryDate date -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) - - UpdateManualEntryHours hours -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) - - UpdateManualEntryType entryType -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) - - SaveAdminTimeEntry -> - case model.token of - Just token -> - ( { model | isProcessing = True }, createAdminTimeEntry token model.adminManualEntryForm ) - - Nothing -> - ( model, Cmd.none ) - - AdminTimeEntrySaved (Ok _) -> - case model.token of - Just token -> - ( { model - | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - AdminTimeEntrySaved (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - FetchMyInfo -> - case model.token of - Just token -> - ( model, fetchMyInfo token ) - - Nothing -> - ( model, Cmd.none ) - - MyInfoReceived (Ok user) -> - ( { model | users = [ user ] }, Cmd.none ) - - MyInfoReceived (Err err) -> - ( model, handleApiError err ) - - FetchSchoolYears -> - case model.token of - Just token -> - ( model, fetchSchoolYears token ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearsReceived (Ok years) -> - ( { model | schoolYears = years }, Cmd.none ) - - SchoolYearsReceived (Err err) -> - ( model, handleApiError err ) - - 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, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - 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 - } - , Cmd.batch - [ fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearCreated (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - 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 - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearActivated (Err err) -> - ( model, handleApiError err ) - - 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 } - , Cmd.batch - [ fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearDeleted (Err err) -> - ( model, handleApiError err ) - - DownloadYearlySummaryPDF -> - case model.token of - Just token -> - ( { model | isProcessing = True }, downloadYearlySummaryPDF token ) - - Nothing -> - ( model, Cmd.none ) - - YearlySummaryPDFReceived (Ok pdfBytes) -> - let - filename = - "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" - in - ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) - - YearlySummaryPDFReceived (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - ShowToast message toastType -> - let - newToast = - { id = model.nextToastId - , message = message - , toastType = toastType - , dismissible = True - } - - dismissDelay = - case toastType of - ErrorToast -> - 8000 - - SuccessToast -> - 5000 - - InfoToast -> - 5000 - - WarningToast -> - 6000 - in - ( { model - | toasts = model.toasts ++ [ newToast ] - , nextToastId = model.nextToastId + 1 - } - , Task.perform (\_ -> AutoDismissToast newToast.id) - (Process.sleep dismissDelay) - ) - - DismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) - - AutoDismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) - - - -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = confirmDeleteResponse DeleteConfirmed - - - --- HELPER FUNCTIONS - - -getISOWeekFromPosix : Time.Posix -> ( Int, Int ) -getISOWeekFromPosix time = - let - year = - Time.toYear Time.utc time - - month = - Time.toMonth Time.utc time |> monthToInt - - day = - Time.toDay Time.utc time - in - ( year, getISOWeek year month day ) - - -monthToInt : Time.Month -> Int -monthToInt month = - case month of - Time.Jan -> - 1 - - Time.Feb -> - 2 - - Time.Mar -> - 3 - - Time.Apr -> - 4 - - Time.May -> - 5 - - Time.Jun -> - 6 - - Time.Jul -> - 7 - - Time.Aug -> - 8 - - Time.Sep -> - 9 - - Time.Oct -> - 10 - - Time.Nov -> - 11 - - Time.Dec -> - 12 - - -getISOWeek : Int -> Int -> Int -> Int -getISOWeek year month day = - let - dayOfYear = - getDayOfYear year month day - - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1DayOfYear = - 4 - jan4DayOfWeek - - weekNum = - ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 - in - if weekNum < 1 then - 52 - - else if weekNum > 52 then - let - dec31DayOfWeek = - getDayOfWeek year 12 31 - - jan1DayOfWeek = - getDayOfWeek year 1 1 - in - if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then - weekNum - - else - 1 - - else - weekNum - - -getDayOfYear : Int -> Int -> Int -> Int -getDayOfYear year month day = - let - daysInMonth = - [ 31 - , if isLeapYear year then - 29 - - else - 28 - , 31 - , 30 - , 31 - , 30 - , 31 - , 31 - , 30 - , 31 - , 30 - , 31 - ] - - daysBefore = - List.take (month - 1) daysInMonth |> List.sum - in - daysBefore + day - - -isLeapYear : Int -> Bool -isLeapYear year = - (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) - - -getDayOfWeek : Int -> Int -> Int -> Int -getDayOfWeek year month day = - let - adjustedMonth = - if month < 3 then - month + 12 - - else - month - - adjustedYear = - if month < 3 then - year - 1 - - else - year - - q = - day - - m = - adjustedMonth - - k = - modBy 100 adjustedYear - - j = - adjustedYear // 100 - - h = - (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 - in - (h + 5) |> modBy 7 - - -getDateForWeekDay : Int -> Int -> Int -> String -getDateForWeekDay year week dayOfWeek = - let - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1Date = - 4 - jan4DayOfWeek - - targetDayOfYear = - mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek - - ( finalYear, finalMonth, finalDay ) = - if targetDayOfYear < 1 then - addDaysToDate (year - 1) 12 31 targetDayOfYear - - else - addDaysToDate year 1 targetDayOfYear 0 - in - String.fromInt finalYear - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalMonth) - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalDay) - - -addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) -addDaysToDate startYear startMonth startDay daysToAdd = - let - daysInMonth m y = - case m of - 1 -> - 31 - - 2 -> - if isLeapYear y then - 29 - - else - 28 - - 3 -> - 31 - - 4 -> - 30 - - 5 -> - 31 - - 6 -> - 30 - - 7 -> - 31 - - 8 -> - 31 - - 9 -> - 30 - - 10 -> - 31 - - 11 -> - 30 - - 12 -> - 31 - - _ -> - 0 - - helper y m d remaining = - if remaining == 0 then - ( y, m, d ) - - else if remaining > 0 then - let - daysInCurrentMonth = - daysInMonth m y - - daysLeftInMonth = - daysInCurrentMonth - d - in - if remaining <= daysLeftInMonth then - ( y, m, d + remaining ) - - else if m == 12 then - helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) - - else - helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) - - else if d + remaining >= 1 then - ( y, m, d + remaining ) - - else if m == 1 then - let - prevMonthDays = - daysInMonth 12 (y - 1) - in - helper (y - 1) 12 prevMonthDays (remaining + d) - - else - let - prevMonthDays = - daysInMonth (m - 1) y - in - helper y (m - 1) prevMonthDays (remaining + d) - in - helper startYear startMonth startDay daysToAdd - - -previousWeek : Int -> Int -> ( Int, Int ) -previousWeek year week = - if week == 1 then - ( year - 1, 52 ) - - else - ( year, week - 1 ) - - -nextWeek : Int -> Int -> ( Int, Int ) -nextWeek year week = - if week >= 52 then - ( year + 1, 1 ) - - else - ( year, week + 1 ) - - -getWeekDateRange : Int -> Int -> String -getWeekDateRange year week = - let - mondayDate = - getDateForWeekDay year week 0 - - fridayDate = - getDateForWeekDay year week 4 - in - mondayDate ++ " bis " ++ fridayDate - - -getYearWeekFromDate : String -> ( Int, Int ) -getYearWeekFromDate dateStr = - let - parts = - String.split "-" dateStr - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - in - ( year, getISOWeek year month day ) - - -calculateHours : String -> String -> Float -calculateHours startTime endTime = - let - parseTime timeStr = - case String.split ":" timeStr of - [ h, m ] -> - (String.toFloat h |> Maybe.withDefault 0) - + ((String.toFloat m |> Maybe.withDefault 0) / 60) - - _ -> - 0 - - start = - parseTime startTime - - end = - parseTime endTime - in - if end > start then - end - start - - else if endTime == "manual" then - case String.toFloat startTime of - Just time -> - time - - Nothing -> - 0 - - else - 0 - - - --- VIEW - - -viewToasts : List Toast -> Html Msg -viewToasts toasts = - div [ class "toast-container" ] - (List.map viewToast toasts) - - -viewToast : Toast -> Html Msg -viewToast toast = - let - toastClass = - case toast.toastType of - ErrorToast -> - "toast-error" - - SuccessToast -> - "toast-success" - - InfoToast -> - "toast-info" - - WarningToast -> - "toast-warning" - - icon = - case toast.toastType of - ErrorToast -> - "fas fa-exclamation-circle" - - SuccessToast -> - "fas fa-check-circle" - - InfoToast -> - "fas fa-info-circle" - - WarningToast -> - "fas fa-exclamation-triangle" - in - div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] - [ div [ class "toast-content" ] - [ span [ class "toast-icon" ] - [ i [ class icon ] [] ] - , span [ class "toast-message" ] [ text toast.message ] - ] - , if toast.dismissible then - button - [ class "toast-close" - , onClick (DismissToast toast.id) - , attribute "aria-label" "Schließen" - ] - [ i [ class "fas fa-times" ] [] ] - - else - text "" - ] - - -view : Model -> Html Msg -view model = - div [ class "app-container" ] - [ viewToasts model.toasts - , div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model - - UserDashboard -> - viewUserDashboard model - - AdminDashboard -> - viewAdminDashboard model - ] - ] - - -viewLogin : Model -> Html Msg -viewLogin model = - section [ class "section" ] - [ div [ class "container" ] - [ div [ class "columns is-centered" ] - [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] - [ div [ class "box" ] - [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.username - , onInput UpdateUsername - ] - [] - ] - ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.password - , onInput UpdatePassword - ] - [] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-fullwidth" - , onClick Login - ] - [ text "Anmelden" ] - ] - ] - ] - ] - ] - ] - ] - - -viewUserDashboard : Model -> Html Msg -viewUserDashboard model = - div [] - [ nav [ class "navbar is-primary" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "role" "navigation" - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarUser" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Stundenplan" ] - , if model.hasEntriesForCurrentWeek && not model.weekEditMode then - div [ class "notification is-success" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-check-circle" ] [] ] - , span [] [ text "Diese Woche wurde bereits erfasst" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-warning" - , onClick EnableEditMode - , disabled model.isProcessing - ] - [ text "Bearbeiten" ] - ] - ] - ] - ] - - else if model.weekEditMode then - div [ class "notification is-warning" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-edit" ] [] ] - , span [] [ text "Bearbeitungsmodus aktiv" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-danger is-small mr-2" - , onClick DeleteWeekEntries - , disabled model.isProcessing - ] - [ text "Einträge löschen" ] - , button - [ class "button is-light is-small" - , onClick DisableEditMode - ] - [ text "Abbrechen" ] - ] - ] - ] - ] - - else - div [ class "notification is-info is-light" ] - [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] - , viewScheduleGridWithWeek model - , if not model.hasEntriesForCurrentWeek || model.weekEditMode then - div [ class "field mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-large is-fullwidth" - , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text - (if model.weekEditMode then - "Änderungen speichern" - - else - "Speichern" - ) - ] - ] - ] - - else - text "" - , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] - , viewUserYearlyTotal model - ] - ] - ] - - -viewAdminDashboard : Model -> Html Msg -viewAdminDashboard model = - div [] - [ nav [ class "navbar is-danger" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarAdmin" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ div [ class "tabs is-boxed" ] - [ ul [] - [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] - [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] - [ 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 - ScheduleTab -> - viewScheduleTab model - - UsersTab -> - viewUsersTab model - - TimeEntriesTab -> - viewTimeEntriesTab model - - SchoolYearsTab -> - viewSchoolYearsTab model - ] - ] - ] - - -viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg -viewScheduleItemWithDay model dayOfWeek schedule = - let - isSelected = - List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries - - isClickable = - (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing - - boxClass = - if isSelected then - "box has-background-success-light" - - else if isClickable then - "box has-background-white" - - else - "box has-background-light" - - typeText = - if schedule.scheduleType == "break" then - " (Pause)" - - else - "" - - cursorStyle = - if isClickable then - "pointer" - - else - "not-allowed" - - opacity = - if isClickable || isSelected then - "1" - - else - "0.6" - in - div - [ class boxClass - , onClick - (if isClickable then - ToggleScheduleSelection schedule.id dayOfWeek - - else - FetchSchedules - ) - , style "cursor" cursorStyle - , style "margin-bottom" "0.5rem" - , style "padding" "0.75rem" - , style "opacity" opacity - , style "transition" "all 0.2s ease" - , style "border" - (if isClickable && not isSelected then - "2px solid transparent" - - else - "2px solid currentColor" - ) - ] - [ p [ class "has-text-weight-bold is-size-7" ] - [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] - [ text (schedule.title ++ typeText) ] - ] - - -viewScheduleGridWithWeek : Model -> Html Msg -viewScheduleGridWithWeek model = - let - days = - [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] - - groupedSchedules = - List.range 0 4 - |> List.map - (\day -> - ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) - ) - in - div [] - [ div [ class "is-hidden-mobile" ] - [ div [ class "table-container" ] - [ table [ class "table is-bordered is-fullwidth" ] - [ thead [] - [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) - ] - , tbody [] - [ tr [] - (List.map (viewDayColumnWithWeek model) groupedSchedules) - ] - ] - ] - ] - , div [ class "is-hidden-tablet" ] - (List.map2 (viewDayMobile model) days groupedSchedules) - ] - - -viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg -viewDayMobile model dayName ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - div [ class "box mb-4" ] - [ p [ class "has-text-weight-bold has-text-centered mb-3" ] - [ text (dayName ++ " - " ++ dateForDay) ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - - -viewUserWeeklySummary : Model -> Html Msg -viewUserWeeklySummary model = - case model.userWeeklySummary of - Just summary -> - let - progressPercent = - Basics.min 100 (summary.totalHours / summary.targetHours * 100) - - progressColor = - if summary.totalHours >= summary.targetHours then - "is-success" - - else if summary.totalHours >= summary.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] - , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] - , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Verbleibend" ] - , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] - [ text (String.fromFloat summary.remainingHours ++ " Std.") ] - , if summary.remainingHours < 0 then - p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] - - else - p [ class "subtitle is-6" ] [ text "" ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - Nothing -> - div [ class "box" ] - [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] - ] - - -viewUserYearlyTotal : Model -> Html Msg -viewUserYearlyTotal model = - let - yearlyTotal = - model.timeEntries - |> List.map - (\entry -> - if entry.entryType == "lesson" then - 1.0 - - else - calculateHours entry.startTime entry.endTime - ) - |> List.sum - - userTarget = - List.filter (\u -> not u.isAdmin) model.users - |> List.head - |> Maybe.map .yearlyWorkHours - |> Maybe.withDefault 60 - - remaining = - userTarget - yearlyTotal - - progressPercent = - Basics.min 100 (yearlyTotal / userTarget * 100) - - progressColor = - if remaining <= 0 then - "is-success" - - else if yearlyTotal >= userTarget * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Jahresenziel" ] - , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Geleistete Stunden" ] - , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Restliche Stunden" ] - , p - [ class - ("title is-4 " - ++ (if remaining <= 0 then - "has-text-success" - - else - "has-text-warning" - ) - ) - ] - [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - -viewScheduleTab : Model -> Html Msg -viewScheduleTab model = - div [] - [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] - , viewScheduleForm model - , viewScheduleList model - ] - - -viewUsersTab : Model -> Html Msg -viewUsersTab model = - div [] - [ h2 [ class "title" ] [ text "Benutzer verwalten" ] - , viewUserForm model - , viewUserList model - ] - - -viewTimeEntriesTab : Model -> Html Msg -viewTimeEntriesTab model = - div [] - [ h2 [ class "title" ] [ text "Jahresübersicht" ] - , viewYearlyHoursSummary model - , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] - , viewAdminManualEntryForm model - , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] - , case model.editingTimeEntryId of - Just _ -> - viewTimeEntriesEditForm model - - Nothing -> - viewTimeEntriesListWithEdit model - ] - - -viewYearlyHoursSummary : Model -> Html Msg -viewYearlyHoursSummary model = - div [ class "box" ] - [ div [ class "level mb-4" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ a - [ class "button is-info" - , onClick DownloadYearlySummaryPDF - , disabled model.isProcessing - ] - [ span [ class "icon" ] - [ i [ class "fas fa-file-pdf" ] [] ] - , span [] - [ text - (if model.isProcessing then - "Wird erstellt..." - - else - "PDF exportieren" - ) - ] - ] - ] - ] - ] - , if List.isEmpty model.yearlyHoursSummary then - p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] - , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] - , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] - , th [ class "has-text-centered" ] [ text "Status" ] - ] - ] - , tbody [] - (List.map viewYearlyHourRow model.yearlyHoursSummary) - ] - ] - - -viewYearlyHourRow : YearlyHoursSummary -> Html Msg -viewYearlyHourRow summary = - let - statusClass = - if summary.remainingYearly > 0 then - "has-text-danger" - - else if abs summary.remainingYearly < 0.5 then - "has-text-success" - - else - "has-text-warning" - in - tr [] - [ td [] [ text summary.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] - , td [ class ("has-text-centered " ++ statusClass) ] - [ if summary.remainingYearly > 0 then - text ("Offen: " ++ String.fromFloat summary.remainingYearly) - - else if summary.remainingYearly < -0.5 then - text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) - - else - text "✓ Erfüllt" - ] - ] - - -viewAdminManualEntryForm : Model -> Html Msg -viewAdminManualEntryForm model = - div [ class "box has-background-info-light" ] - [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] - , p [ class "help mb-3" ] - [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] - , div [ class "columns" ] - [ div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Mitarbeiter" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] - (option [ value "" ] [ text "-- Wählen --" ] - :: List.map - (\u -> - option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] - ) - model.users - ) - ] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.adminManualEntryForm.date - , onInput UpdateManualEntryDate - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "number" - , step "0.5" - , placeholder "z.B. 2.5 oder -1.0" - , value model.adminManualEntryForm.hours - , onInput UpdateManualEntryHours - ] - [] - ] - , p [ class "help" ] - [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-info" - , onClick SaveAdminTimeEntry - , disabled - (case model.adminManualEntryForm.selectedUserId of - Just _ -> - model.isProcessing || String.isEmpty model.adminManualEntryForm.hours - - Nothing -> - True - ) - ] - [ text "Eintrag erstellen" ] - ] - ] - ] - - -viewTimeEntriesEditForm : Model -> Html Msg -viewTimeEntriesEditForm model = - div [ class "box has-background-warning-light" ] - [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-success" - , onClick SaveEditTimeEntry - ] - [ text "Speichern" ] - ] - , div [ class "control" ] - [ button - [ class "button is-light" - , onClick CancelEditTimeEntry - ] - [ text "Abbrechen" ] - ] - ] - , viewTimeEntriesListWithEdit model - ] - - -viewTimeEntriesListWithEdit : Model -> Html Msg -viewTimeEntriesListWithEdit model = - div [ class "box" ] - [ if List.isEmpty model.timeEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) - ] - ] - - -viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithEdit model entry = - let - hours = - calculateHours entry.startTime entry.endTime - - isEditing = - model.editingTimeEntryId == Just entry.id - in - if isEditing then - tr [] - [ td [] [ text entry.username ] - , td [] - [ input - [ class "input is-small" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - , td [] - [ div [ class "field is-grouped" ] - [ div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - , div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , td [] - [ div [ class "select is-small" ] - [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - , td [ class "has-text-right" ] [ text "" ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditTimeEntry entry.id) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-small is-danger" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - - -viewWeekNavigation : Model -> Html Msg -viewWeekNavigation model = - let - dateRange = - case model.weekDates of - Just wd -> - wd.range - - Nothing -> - "Laden..." - in - div [ class "box" ] - [ nav [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick PreviousWeek - ] - [ span [ class "icon" ] - [ i [ class "fas fa-chevron-left" ] [] ] - , span [] [ text "Vorherige Woche" ] - ] - ] - ] - , div [ class "level-item" ] - [ div - [ style "display" "flex" - , style "flex-direction" "column" - , style "align-items" "center" - , style "gap" "0.5rem" - , style "min-width" "250px" - ] - [ p - [ class "heading" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text "Kalenderwoche" ] - , p - [ class "title is-3" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p - [ class "subtitle is-6" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text dateRange ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick NextWeek - ] - [ span [] [ text "Nächste Woche" ] - , span [ class "icon" ] - [ i [ class "fas fa-chevron-right" ] [] ] - ] - ] - ] - ] - ] - - -viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg -viewDayColumnWithWeek model ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] - [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] - [ text dateForDay ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - - -viewScheduleForm : Model -> Html Msg -viewScheduleForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Wochentag" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleDay - , disabled model.isProcessing - , value model.newSchedule.dayOfWeek - ] - [ option [ value "" ] [ text "Wochentag wählen" ] - , option [ value "0" ] [ text "Montag" ] - , option [ value "1" ] [ text "Dienstag" ] - , option [ value "2" ] [ text "Mittwoch" ] - , option [ value "3" ] [ text "Donnerstag" ] - , option [ value "4" ] [ text "Freitag" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.startTime - , onInput UpdateNewScheduleStart - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.endTime - , onInput UpdateNewScheduleEnd - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleType - , value model.newSchedule.scheduleType - , disabled model.isProcessing - ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Titel" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "z.B. Mathematik" - , value model.newSchedule.title - , onInput UpdateNewScheduleTitle - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary" - , onClick CreateSchedule - , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text " Hinzufügen" - ] - ] - ] - , if String.isEmpty model.newSchedule.dayOfWeek then - div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] - - else - text "" - ] - - -viewScheduleList : Model -> Html Msg -viewScheduleList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Tag" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [] [ text "Titel" ] - , th [] [ text "Aktion" ] - ] - ] - , tbody [] - (List.map viewScheduleRow model.schedules) - ] - ] - - -viewScheduleRow : Schedule -> Html Msg -viewScheduleRow schedule = - let - dayName = - case schedule.dayOfWeek of - 0 -> - "Montag" - - 1 -> - "Dienstag" - - 2 -> - "Mittwoch" - - 3 -> - "Donnerstag" - - 4 -> - "Freitag" - - _ -> - "Unbekannt" - - typeName = - if schedule.scheduleType == "break" then - "Pause" - - else - "Unterricht" - in - tr [] - [ td [] [ text dayName ] - , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , td [] [ text typeName ] - , td [] [ text schedule.title ] - , td [] - [ button - [ class "button is-small is-danger" - , onClick (DeleteSchedule schedule.id) - ] - [ text "Löschen" ] - ] - ] - - -viewUserForm : Model -> Html Msg -viewUserForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.newUser.username - , onInput UpdateNewUsername - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.newUser.password - , onInput UpdateNewPassword - ] - [] - ] - ] - ] - , div [ class "column is-narrow" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Admin" ] - , div [ class "control" ] - [ label [ class "checkbox" ] - [ input - [ type_ "checkbox" - , checked model.newUser.isAdmin - , onCheck UpdateNewUserAdmin - ] - [] - , text " Admin-Rechte" - ] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] - ] - ] - ] - - -viewUserList : Model -> Html Msg -viewUserList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Benutzer" ] - , if List.isEmpty model.users then - p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "ID" ] - , th [] [ text "Benutzername" ] - , th [] [ text "Rolle" ] - , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewUserRowWithActions model) model.users) - ] - ] - - -viewUserRowWithActions : Model -> User -> Html Msg -viewUserRowWithActions model user = - if model.editingUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "number" - , step "0.5" - , value model.editingUserWorkHours - , onInput UpdateEditUserWorkHours - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] - ] - ] - - else if model.resetPasswordUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "password" - , placeholder "Neues Passwort" - , value model.resetPasswordNew - , onInput UpdateResetPasswordNew - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - div [] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditUserWorkHours user.id) - ] - [ text "Arbeitszeit" ] - , button - [ class "button is-small is-warning mr-2" - , onClick (ResetUserPassword user.id) - ] - [ text "PW Reset" ] - , button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -viewUserRow : User -> Html Msg -viewUserRow user = - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - - -viewWeeklyHoursSummary : Model -> Html Msg -viewWeeklyHoursSummary model = - let - filteredHours = - List.filter - (\h -> h.week == model.currentWeek && h.year == model.currentYear) - model.weeklyHours - in - div [ class "box" ] - [ if List.isEmpty filteredHours then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Arbeitet" ] - , th [ class "has-text-right" ] [ text "Soll" ] - , th [ class "has-text-right" ] [ text "Verbleibend" ] - , th [] [ text "Fortschritt" ] - ] - ] - , tbody [] - (List.map viewWeeklyHoursRow filteredHours) - , tfoot [] - [ tr [ class "has-background-light" ] - [ th [] [ text "Gesamt" ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] - , th [] [ text "" ] - , th [] [ text "" ] - ] - ] - ] - ] - - -viewWeeklyHoursRow : WeeklyHours -> Html Msg -viewWeeklyHoursRow hours = - let - progressPercent = - Basics.min 100 (hours.totalHours / hours.targetHours * 100) - - progressColor = - if hours.totalHours >= hours.targetHours then - "is-success" - - else if hours.totalHours >= hours.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - tr [] - [ td [] [ text hours.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] - , td [] - [ progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [] - ] - ] - - -viewTimeEntriesList : Model -> Html Msg -viewTimeEntriesList model = - let - filteredEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in - div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithActions model) filteredEntries) - ] - ] - - -viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithActions model entry = - let - hours = - if entry.entryType == "lesson" then - 1.0 - - else - calculateHours entry.startTime entry.endTime - in - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [] - [ div [ class "buttons are-small" ] - [ button - [ class "button is-info is-small" - , onClick (StartEditingTimeEntry entry.id entry) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-danger is-small" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -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 - - -type alias LoginResult = - { token : String - , username : String - , isAdmin : Bool - } - - -loginRequest : String -> String -> Cmd Msg -loginRequest username password = - Http.post - { url = "/api/login" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string username ) - , ( "password", Encode.string password ) - ] - , expect = Http.expectJson LoginResponse loginDecoder - } - - -loginDecoder : Decoder LoginResult -loginDecoder = - Decode.map3 LoginResult - (field "token" string) - (field "username" string) - (field "is_admin" bool) - - -fetchSchedules : Maybe String -> Cmd Msg -fetchSchedules maybeToken = - case maybeToken of - Just token -> - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/schedules" - , body = Http.emptyBody - , expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder) - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -scheduleDecoder : Decoder Schedule -scheduleDecoder = - Decode.map6 Schedule - (field "id" int) - (field "day_of_week" int) - (field "start_time" string) - (field "end_time" string) - (field "type" string) - (field "title" string) - - -fetchMyTimeEntries : String -> Cmd Msg -fetchMyTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries" - , body = Http.emptyBody - , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg -saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = - case maybeWeekDates of - Nothing -> - Cmd.none - - Just weekDates -> - let - getScheduleById id = - List.filter (\s -> s.id == id) schedules |> List.head - - getDateForDay dayOfWeek = - weekDates.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - - createEntryData entry = - case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of - ( Just schedule, Just dateStr ) -> - Just <| - Encode.object - [ ( "schedule_id", Encode.int entry.scheduleId ) - , ( "date", Encode.string dateStr ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - ] - - _ -> - Nothing - - entriesData = - List.filterMap createEntryData selectedEntries - in - if List.isEmpty entriesData then - Cmd.none - - else - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/time-entries/batch" - , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] - , expect = Http.expectWhatever TimeEntriesSaved - , timeout = Nothing - , tracker = Nothing - } - - -deleteWeekEntries : String -> Int -> Int -> Cmd Msg -deleteWeekEntries token year week = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectWhatever WeekEntriesDeleted - , timeout = Nothing - , tracker = Nothing - } - - -createSchedule : String -> NewSchedule -> Cmd Msg -createSchedule token schedule = - case String.toInt schedule.dayOfWeek of - Just day -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules" - , body = - Http.jsonBody <| - Encode.object - [ ( "day_of_week", Encode.int day ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "title", Encode.string schedule.title ) - ] - , expect = Http.expectWhatever ScheduleCreated - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -deleteSchedule : String -> Int -> Cmd Msg -deleteSchedule token scheduleId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId - , body = Http.emptyBody - , expect = Http.expectWhatever ScheduleDeleted - , timeout = Nothing - , tracker = Nothing - } - - -createUser : String -> NewUser -> Cmd Msg -createUser token user = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string user.username ) - , ( "password", Encode.string user.password ) - , ( "is_admin", Encode.bool user.isAdmin ) - ] - , expect = Http.expectWhatever UserCreated - , timeout = Nothing - , tracker = Nothing - } - - -deleteUser : String -> Int -> Cmd Msg -deleteUser token userId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/delete?id=" ++ String.fromInt userId - , body = Http.emptyBody - , expect = Http.expectWhatever UserDeleted - , timeout = Nothing - , tracker = Nothing - } - - -fetchUsers : String -> Cmd Msg -fetchUsers token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/list" - , body = Http.emptyBody - , expect = Http.expectJson UsersReceived (Decode.list userDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -userDecoder : Decoder User -userDecoder = - Decode.map4 User - (field "id" int) - (field "username" string) - (field "is_admin" bool) - (field "yearly_hours" float) - - -fetchAllTimeEntries : String -> Cmd Msg -fetchAllTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries" - , body = Http.emptyBody - , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -timeEntryDecoder : Decoder TimeEntry -timeEntryDecoder = - Decode.map8 TimeEntry - (field "id" int) - (field "user_id" int) - (field "schedule_id" int) - (field "date" string) - (field "type" string) - (field "username" string) - (field "start_time" string) - (field "end_time" string) - - -fetchWeeklyHours : String -> Cmd Msg -fetchWeeklyHours token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/weekly-hours" - , body = Http.emptyBody - , expect = Http.expectJson WeeklyHoursReceived (Decode.list weeklyHoursDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -weeklyHoursDecoder : Decoder WeeklyHours -weeklyHoursDecoder = - Decode.map7 WeeklyHours - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) - (field "remaining_hours" float) - - -fetchYearlyHoursSummary : String -> Cmd Msg -fetchYearlyHoursSummary token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/yearly-hours-summary" - , body = Http.emptyBody - , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary -yearlyHoursSummaryDecoder = - Decode.succeed YearlyHoursSummary - |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) - |> Decode.andThen (\f -> Decode.map f (field "username" string)) - |> Decode.andThen (\f -> Decode.map f (field "year" int)) - |> Decode.andThen (\f -> Decode.map f (field "week" int)) - |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) - |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) - - -createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg -createAdminTimeEntry token entry = - case entry.selectedUserId of - Just userId -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entry" - , body = - Http.jsonBody <| - Encode.object - [ ( "user_id", Encode.int userId ) - , ( "date", Encode.string entry.date ) - , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) - , ( "type", Encode.string "manual" ) - ] - , expect = Http.expectWhatever AdminTimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -fetchWeekDates : String -> Int -> Int -> Cmd Msg -fetchWeekDates token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekDatesReceived weekDatesDecoder - , timeout = Nothing - , tracker = Nothing - } - - -weekDatesDecoder : Decoder WeekDates -weekDatesDecoder = - Decode.map4 WeekDates - (field "year" int) - (field "week" int) - (field "dates" (Decode.dict string) |> Decode.map Dict.toList) - (field "range" string) - - -checkWeekHasEntries : String -> Int -> Int -> Cmd Msg -checkWeekHasEntries token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) - , timeout = Nothing - , tracker = Nothing - } - - -updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg -updateTimeEntry token entry = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId - , body = - Http.jsonBody <| - Encode.object - [ ( "date", Encode.string entry.date ) - , ( "start_time", Encode.string entry.startTime ) - , ( "end_time", Encode.string entry.endTime ) - , ( "type", Encode.string entry.entryType ) - ] - , expect = Http.expectWhatever TimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - -deleteTimeEntry : String -> Int -> Cmd Msg -deleteTimeEntry token entryId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entryId - , body = Http.emptyBody - , expect = Http.expectWhatever TimeEntryDeleted - , timeout = Nothing - , tracker = Nothing - } - - -updateUserWorkHours : String -> Int -> String -> Cmd Msg -updateUserWorkHours token userId hours = - case String.toFloat hours of - Just workHours -> - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId - , body = - Http.jsonBody <| - Encode.object - [ ( "yearly_hours", Encode.float workHours ) ] - , expect = Http.expectWhatever UserWorkHoursSaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -resetUserPassword : String -> Int -> String -> Cmd Msg -resetUserPassword token userId newPassword = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" - , body = - Http.jsonBody <| - Encode.object - [ ( "new_password", Encode.string newPassword ) ] - , expect = Http.expectWhatever ResetPasswordSaved - , timeout = Nothing - , tracker = Nothing - } - - -fetchMyInfo : String -> Cmd Msg -fetchMyInfo token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-info" - , body = Http.emptyBody - , expect = Http.expectJson MyInfoReceived userDecoder - , 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) - - -downloadYearlySummaryPDF : String -> Cmd Msg -downloadYearlySummaryPDF token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/yearly-summary/pdf" - , body = Http.emptyBody - , expect = - Http.expectBytesResponse YearlySummaryPDFReceived - (\response -> - case response of - Http.GoodStatus_ _ body -> - Ok body - - Http.BadUrl_ url -> - Err (Http.BadUrl url) - - Http.Timeout_ -> - Err Http.Timeout - - Http.NetworkError_ -> - Err Http.NetworkError - - Http.BadStatus_ metadata _ -> - Err (Http.BadStatus metadata.statusCode) - ) - , timeout = Nothing - , tracker = Nothing - } - - -type alias ApiError = - { code : String - , message : String - } - - -apiErrorDecoder : Decoder ApiError -apiErrorDecoder = - Decode.map2 ApiError - (field "code" string) - (field "message" string) - - -handleApiError : Http.Error -> Cmd Msg -handleApiError error = - let - message = - case error of - Http.BadBody body -> - case Decode.decodeString apiErrorDecoder body of - Ok apiErr -> - apiErr.message - - Err _ -> - "Ein Fehler ist aufgetreten" - - Http.BadStatus 401 -> - "Keine Berechtigung - bitte erneut anmelden" - - Http.BadStatus 403 -> - "Zugriff verweigert" - - Http.BadStatus 404 -> - "Ressource nicht gefunden" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Ein unerwarteter Fehler ist aufgetreten" - in - Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm new file mode 100644 index 0000000..aae29d0 --- /dev/null +++ b/frontend/src/Types/Api.elm @@ -0,0 +1,17 @@ +module Types.Api exposing + ( ApiError + , LoginResult + ) + + +type alias LoginResult = + { token : String + , username : String + , isAdmin : Bool + } + + +type alias ApiError = + { code : String + , message : String + } diff --git a/frontend/src/Types/Model.elm b/frontend/src/Types/Model.elm new file mode 100644 index 0000000..64911d6 --- /dev/null +++ b/frontend/src/Types/Model.elm @@ -0,0 +1,218 @@ +module Types.Model exposing + ( AdminManualEntry + , EditingTimeEntry + , Flags + , Model + , NewSchedule + , NewSchoolYear + , NewUser + , Schedule + , SchoolYear + , SelectedEntry + , TimeEntry + , Toast + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) + +import Time +import Types.Page exposing (AdminTab, Page) + + +type alias Model = + { page : Page + , activeTab : AdminTab + , username : String + , password : String + , token : Maybe String + , isAdmin : Bool + , schedules : List Schedule + , users : List User + , timeEntries : List TimeEntry + , weeklyHours : List WeeklyHours + , yearlyHoursSummary : List YearlyHoursSummary + , selectedEntries : List SelectedEntry + , currentWeek : Int + , currentYear : Int + , weekDates : Maybe WeekDates + , currentTime : Time.Posix + , zone : Time.Zone + , newSchedule : NewSchedule + , newUser : NewUser + , error : Maybe String + , weekEditMode : Bool + , hasEntriesForCurrentWeek : Bool + , userWeeklySummary : Maybe WeeklySummary + , editingTimeEntryId : Maybe Int + , editingTimeEntry : EditingTimeEntry + , editingUserId : Maybe Int + , editingUserWorkHours : String + , resetPasswordUserId : Maybe Int + , resetPasswordNew : String + , pendingDeleteId : Maybe Int + , selectedUserId : Maybe Int + , userWorkHoursInput : String + , userPasswordInput : String + , isProcessing : Bool + , mobileMenuOpen : Bool + , adminManualEntryForm : AdminManualEntry + , schoolYears : List SchoolYear + , newSchoolYear : NewSchoolYear + , activeSchoolYear : Maybe SchoolYear + , editingSchoolYearId : Maybe Int + , toasts : List Toast + , nextToastId : Int + } + + +type ToastType + = ErrorToast + | SuccessToast + | InfoToast + | WarningToast + + +type alias Toast = + { id : Int + , message : String + , toastType : ToastType + , dismissible : Bool + } + + +type alias Flags = + { token : Maybe String + , isAdmin : Bool + } + + +type alias Schedule = + { id : Int + , dayOfWeek : Int + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias User = + { id : Int + , username : String + , isAdmin : Bool + , yearlyWorkHours : Float + } + + +type alias TimeEntry = + { id : Int + , userId : Int + , scheduleId : Int + , date : String + , entryType : String + , username : String + , startTime : String + , endTime : String + } + + +type alias SelectedEntry = + { scheduleId : Int + , dayOfWeek : Int + } + + +type alias NewSchedule = + { dayOfWeek : String + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias NewUser = + { username : String + , password : String + , isAdmin : Bool + } + + +type alias WeekDates = + { year : Int + , week : Int + , dates : List ( String, String ) + , range : String + } + + +type alias WeeklySummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias EditingTimeEntry = + { entryId : Int + , date : String + , startTime : String + , endTime : String + , entryType : String + } + + +type alias WeeklyHours = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias YearlyHoursSummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , yearlyTarget : Float + , yearlyActual : Float + , weeklyTarget : Float + , remainingYearly : Float + } + + +type alias AdminManualEntry = + { selectedUserId : Maybe Int + , date : String + , hours : String + , entryType : String + } + + +type alias SchoolYear = + { id : Int + , name : String + , startDate : String + , endDate : String + , isActive : Bool + } + + +type alias NewSchoolYear = + { name : String + , startDate : String + , endDate : String + } diff --git a/frontend/src/Types/Msg.elm b/frontend/src/Types/Msg.elm new file mode 100644 index 0000000..4158571 --- /dev/null +++ b/frontend/src/Types/Msg.elm @@ -0,0 +1,133 @@ +module Types.Msg exposing (Msg(..)) + +import Bytes exposing (Bytes) +import Http +import Time +import Types.Api exposing (LoginResult) +import Types.Model + exposing + ( Schedule + , SchoolYear + , TimeEntry + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) +import Types.Page exposing (AdminTab) + + +type Msg + = UpdateUsername String + | UpdatePassword String + | Login + | LoginResponse (Result Http.Error LoginResult) + | Logout + | SetTime Time.Posix + | FetchSchedules + | SchedulesReceived (Result Http.Error (List Schedule)) + | ToggleScheduleSelection Int Int + | SaveTimeEntries + | TimeEntriesSaved (Result Http.Error ()) + | PreviousWeek + | NextWeek + | EnableEditMode + | DisableEditMode + | DeleteWeekEntries + | WeekEntriesDeleted (Result Http.Error ()) + | SwitchTab AdminTab + | UpdateNewScheduleDay String + | UpdateNewScheduleStart String + | UpdateNewScheduleEnd String + | UpdateNewScheduleType String + | UpdateNewScheduleTitle String + | CreateSchedule + | ScheduleCreated (Result Http.Error ()) + | DeleteSchedule Int + | ScheduleDeleted (Result Http.Error ()) + | UpdateNewUsername String + | UpdateNewPassword String + | UpdateNewUserAdmin Bool + | CreateUser + | UserCreated (Result Http.Error ()) + | DeleteUser Int + | UserDeleted (Result Http.Error ()) + | FetchUsers + | UsersReceived (Result Http.Error (List User)) + | FetchMyTimeEntries + | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchAllTimeEntries + | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchWeeklyHours + | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) + | FetchYearlyHoursSummary + | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) + | FetchWeekDates + | WeekDatesReceived (Result Http.Error WeekDates) + | CheckWeekHasEntries + | WeekHasEntriesReceived (Result Http.Error Bool) + | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) + | EditTimeEntry Int + | CancelEditTimeEntry + | UpdateEditTimeEntryDate String + | UpdateEditTimeEntryStartTime String + | UpdateEditTimeEntryEndTime String + | UpdateEditTimeEntryType String + | SaveEditTimeEntry + | TimeEntrySaved (Result Http.Error ()) + | TimeEntryDeleted (Result Http.Error ()) + | EditUserWorkHours Int + | CancelEditUserWorkHours + | UpdateEditUserWorkHours String + | SaveUserWorkHours + | UserWorkHoursSaved (Result Http.Error ()) + | ResetUserPassword Int + | CancelResetPassword + | UpdateResetPasswordNew String + | SaveResetPassword + | ResetPasswordSaved (Result Http.Error ()) + | ConfirmDeleteTimeEntry Int + | ConfirmDeleteUser Int + | DeleteConfirmed Bool + | StartEditingTimeEntry Int TimeEntry + | CancelEditingTimeEntry + | UpdateEditingTimeEntryDate String + | UpdateEditingTimeEntryStartTime String + | UpdateEditingTimeEntryEndTime String + | UpdateEditingTimeEntryType String + | SaveEditingTimeEntry + | SelectUserForManagement Int + | UpdateUserWorkHours String + | UpdateUserPassword String + | SaveUserPassword + | UserPasswordSaved (Result Http.Error ()) + | ToggleMobileMenu + | CloseMobileMenu + | SelectUserForManualEntry Int + | UpdateManualEntryDate String + | UpdateManualEntryHours String + | UpdateManualEntryType String + | SaveAdminTimeEntry + | 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 ()) + | DownloadYearlySummaryPDF + | YearlySummaryPDFReceived (Result Http.Error Bytes) + | ShowToast String ToastType + | DismissToast Int + | AutoDismissToast Int diff --git a/frontend/src/Types/Page.elm b/frontend/src/Types/Page.elm new file mode 100644 index 0000000..5b41054 --- /dev/null +++ b/frontend/src/Types/Page.elm @@ -0,0 +1,17 @@ +module Types.Page exposing + ( AdminTab(..) + , Page(..) + ) + + +type Page + = LoginPage + | UserDashboard + | AdminDashboard + + +type AdminTab + = ScheduleTab + | UsersTab + | TimeEntriesTab + | SchoolYearsTab diff --git a/frontend/src/Update/AuthUpdate.elm b/frontend/src/Update/AuthUpdate.elm new file mode 100644 index 0000000..20a1fbc --- /dev/null +++ b/frontend/src/Update/AuthUpdate.elm @@ -0,0 +1,115 @@ +module Update.AuthUpdate exposing + ( handleLogin + , handleLoginResponse + , handleLogout + ) + +import Api.Auth +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import Http +import Json.Encode as Encode +import Task +import Types.Model exposing (Model, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import Utils.DateUtils exposing (getISOWeekFromPosix) +import Utils.Ports exposing (removeToken, saveToken) + + +handleLogin : Model -> ( Model, Cmd Msg ) +handleLogin model = + if model.isProcessing then + ( model, Cmd.none ) + + else + ( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password ) + + +handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg ) +handleLoginResponse result model = + case result of + Ok loginResult -> + let + newPage = + if loginResult.isAdmin then + AdminDashboard + + else + UserDashboard + + ( year, week ) = + getISOWeekFromPosix model.currentTime + + tokenData = + Encode.object + [ ( "token", Encode.string loginResult.token ) + , ( "isAdmin", Encode.bool loginResult.isAdmin ) + ] + in + ( { model + | token = Just loginResult.token + , username = loginResult.username + , isAdmin = loginResult.isAdmin + , page = newPage + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ saveToken tokenData + , Api.Schedule.fetchSchedules (Just loginResult.token) + , Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ()) + , if not loginResult.isAdmin then + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + , Api.User.fetchMyInfo loginResult.token + ] + + else + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + ] + ] + ) + + Err err -> + let + errorMsg = + case err of + Http.BadStatus 401 -> + "Benutzername oder Passwort ungültig" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Anmeldung fehlgeschlagen" + in + ( { model | isProcessing = False } + , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) + ) + + +handleLogout : Model -> ( Model, Cmd Msg ) +handleLogout model = + ( { model + | page = LoginPage + , token = Nothing + , isAdmin = False + , username = "" + , password = "" + , isProcessing = False + } + , removeToken () + ) diff --git a/frontend/src/Update/ScheduleUpdate.elm b/frontend/src/Update/ScheduleUpdate.elm new file mode 100644 index 0000000..2312e13 --- /dev/null +++ b/frontend/src/Update/ScheduleUpdate.elm @@ -0,0 +1,244 @@ +module Update.ScheduleUpdate exposing + ( handleCreateSchedule + , handleDeleteSchedule + , handleDeleteWeekEntries + , handleDisableEditMode + , handleEnableEditMode + , handleSaveTimeEntries + , handleScheduleCreated + , handleScheduleDeleted + , handleSchedulesReceived + , handleTimeEntriesSaved + , handleToggleScheduleSelection + , handleWeekEntriesDeleted + ) + +import Api.Schedule +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate) + + +handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg ) +handleToggleScheduleSelection scheduleId dayOfWeek model = + let + entry = + { scheduleId = scheduleId, dayOfWeek = dayOfWeek } + + newSelected = + if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then + List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries + + else + entry :: model.selectedEntries + in + ( { model | selectedEntries = newSelected }, Cmd.none ) + + +handleSaveTimeEntries : Model -> ( Model, Cmd Msg ) +handleSaveTimeEntries model = + case model.token of + Just token -> + ( { model | error = Nothing } + , Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates + ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntriesSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | error = Nothing + , weekEditMode = False + , hasEntriesForCurrentWeek = True + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEnableEditMode : Model -> ( Model, Cmd Msg ) +handleEnableEditMode model = + let + currentWeekEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + + preSelectedEntries = + List.map + (\entry -> + let + parts = + String.split "-" entry.date + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + dayOfWeek = + getDayOfWeek year month day + in + { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } + ) + currentWeekEntries + in + ( { model + | weekEditMode = True + , selectedEntries = preSelectedEntries + } + , Cmd.none + ) + + +handleDisableEditMode : Model -> ( Model, Cmd Msg ) +handleDisableEditMode model = + ( { model | weekEditMode = False }, Cmd.none ) + + +handleDeleteWeekEntries : Model -> ( Model, Cmd Msg ) +handleDeleteWeekEntries model = + case model.token of + Just token -> + ( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + +handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleWeekEntriesDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | weekEditMode = True + , selectedEntries = [] + , hasEntriesForCurrentWeek = False + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleCreateSchedule : Model -> ( Model, Cmd Msg ) +handleCreateSchedule model = + if + String.isEmpty model.newSchedule.dayOfWeek + || String.isEmpty model.newSchedule.startTime + || String.isEmpty model.newSchedule.endTime + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + let + emptySchedule = + NewSchedule "" "" "" "lesson" "" + in + ( { model + | newSchedule = emptySchedule + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.Schedule.fetchSchedules model.token + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchedule scheduleId model = + case model.token of + Just token -> + ( model, Api.Schedule.deleteSchedule token scheduleId ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.Schedule.fetchSchedules (Just token) + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg ) +handleSchedulesReceived result model = + case result of + Ok schedules -> + ( { model | schedules = schedules }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/SchoolYearUpdate.elm b/frontend/src/Update/SchoolYearUpdate.elm new file mode 100644 index 0000000..0de741d --- /dev/null +++ b/frontend/src/Update/SchoolYearUpdate.elm @@ -0,0 +1,139 @@ +module Update.SchoolYearUpdate exposing + ( handleActivateSchoolYear + , handleActiveSchoolYearReceived + , handleCreateSchoolYear + , handleDeleteSchoolYear + , handleSchoolYearActivated + , handleSchoolYearCreated + , handleSchoolYearDeleted + , handleSchoolYearsReceived + ) + +import Api.SchoolYear +import Http +import Task +import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleCreateSchoolYear : Model -> ( Model, Cmd Msg ) +handleCreateSchoolYear model = + if + String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | newSchoolYear = NewSchoolYear "" "" "" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleActivateSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.activateSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearActivated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.deleteSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg ) +handleSchoolYearsReceived result model = + case result of + Ok years -> + ( { model | schoolYears = years }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg ) +handleActiveSchoolYearReceived result model = + case result of + Ok year -> + ( { model | activeSchoolYear = Just year }, Cmd.none ) + + Err _ -> + ( { model | activeSchoolYear = Nothing }, Cmd.none ) diff --git a/frontend/src/Update/TimeEntryUpdate.elm b/frontend/src/Update/TimeEntryUpdate.elm new file mode 100644 index 0000000..a794944 --- /dev/null +++ b/frontend/src/Update/TimeEntryUpdate.elm @@ -0,0 +1,189 @@ +module Update.TimeEntryUpdate exposing + ( handleAdminTimeEntrySaved + , handleAllTimeEntriesReceived + , handleConfirmDeleteTimeEntry + , handleEditTimeEntry + , handleMyTimeEntriesReceived + , handleSaveAdminTimeEntry + , handleSaveEditTimeEntry + , handleTimeEntryDeleted + , handleTimeEntrySaved + , handleYearlyHoursSummaryReceived + ) + +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.Ports exposing (confirmDelete) + + +handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleMyTimeEntriesReceived result model = + case result of + Ok entries -> + let + hasEntries = + List.any + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + entries + in + ( { model + | timeEntries = entries + , hasEntriesForCurrentWeek = hasEntries + , weekEditMode = False + } + , Cmd.none + ) + + Err err -> + ( model, Cmd.none ) + + +handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleAllTimeEntriesReceived result model = + case result of + Ok entries -> + ( { model | timeEntries = entries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleEditTimeEntry entryId model = + case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of + Just entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = + { entryId = entryId + , date = entry.date + , startTime = entry.startTime + , endTime = entry.endTime + , entryType = entry.entryType + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveEditTimeEntry model = + case model.token of + Just token -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntryDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleConfirmDeleteTimeEntry entryId model = + ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) + + +handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveAdminTimeEntry model = + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm ) + + Nothing -> + ( model, Cmd.none ) + + +handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleAdminTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg ) +handleYearlyHoursSummaryReceived result model = + case result of + Ok summary -> + ( { model | yearlyHoursSummary = summary }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/Update.elm b/frontend/src/Update/Update.elm new file mode 100644 index 0000000..f384b8c --- /dev/null +++ b/frontend/src/Update/Update.elm @@ -0,0 +1,811 @@ +module Update.Update exposing (update) + +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import File.Download +import Process +import Task +import Time +import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..), Page(..)) +import Update.AuthUpdate as Auth +import Update.ScheduleUpdate as Schedule +import Update.SchoolYearUpdate as SchoolYear +import Update.TimeEntryUpdate as TimeEntry +import Update.UserUpdate as User +import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek) +import Utils.Ports + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + -- Mobile Menu + ToggleMobileMenu -> + ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) + + CloseMobileMenu -> + ( { model | mobileMenuOpen = False }, Cmd.none ) + + -- Auth + UpdateUsername username -> + ( { model | username = username }, Cmd.none ) + + UpdatePassword password -> + ( { model | password = password }, Cmd.none ) + + Login -> + Auth.handleLogin model + + LoginResponse result -> + Auth.handleLoginResponse result model + + Logout -> + Auth.handleLogout model + + -- Time + SetTime time -> + let + ( year, week ) = + getISOWeekFromPosix time + + cmds = + case model.token of + Just token -> + if model.page == UserDashboard || model.page == LoginPage then + Cmd.batch + [ Api.TimeEntry.checkWeekHasEntries token year week + , Api.TimeEntry.fetchWeekDates token year week + , Api.TimeEntry.fetchMyTimeEntries token + ] + + else + Cmd.none + + Nothing -> + Cmd.none + in + ( { model + | currentTime = time + , currentWeek = week + , currentYear = year + } + , cmds + ) + + -- Schedules + FetchSchedules -> + ( model, Api.Schedule.fetchSchedules model.token ) + + SchedulesReceived result -> + Schedule.handleSchedulesReceived result model + + ToggleScheduleSelection scheduleId dayOfWeek -> + Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model + + SaveTimeEntries -> + Schedule.handleSaveTimeEntries model + + TimeEntriesSaved result -> + Schedule.handleTimeEntriesSaved result model + + EnableEditMode -> + Schedule.handleEnableEditMode model + + DisableEditMode -> + Schedule.handleDisableEditMode model + + DeleteWeekEntries -> + Schedule.handleDeleteWeekEntries model + + WeekEntriesDeleted result -> + Schedule.handleWeekEntriesDeleted result model + + CreateSchedule -> + Schedule.handleCreateSchedule model + + ScheduleCreated result -> + Schedule.handleScheduleCreated result model + + DeleteSchedule scheduleId -> + Schedule.handleDeleteSchedule scheduleId model + + ScheduleDeleted result -> + Schedule.handleScheduleDeleted result model + + -- Week Navigation + PreviousWeek -> + let + ( newYear, newWeek ) = + previousWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + NextWeek -> + let + ( newYear, newWeek ) = + nextWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + FetchWeekDates -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekDatesReceived result -> + case result of + Ok weekDates -> + ( { model | weekDates = Just weekDates }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + CheckWeekHasEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekHasEntriesReceived result -> + case result of + Ok hasEntries -> + ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- Admin Tabs + SwitchTab tab -> + let + cmd = + case tab of + UsersTab -> + case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + + TimeEntriesTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + ] + + Nothing -> + Cmd.none + + SchoolYearsTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + ] + + Nothing -> + Cmd.none + + _ -> + Cmd.none + in + ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) + + -- Schedule Form + UpdateNewScheduleDay day -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | dayOfWeek = day } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleStart time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | startTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleEnd time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | endTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleType scheduleType -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | scheduleType = scheduleType } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleTitle title -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | title = title } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + -- Users + UpdateNewUsername username -> + let + oldUser = + model.newUser + + newUser = + { oldUser | username = username } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewPassword password -> + let + oldUser = + model.newUser + + newUser = + { oldUser | password = password } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewUserAdmin isAdmin -> + let + oldUser = + model.newUser + + newUser = + { oldUser | isAdmin = isAdmin } + in + ( { model | newUser = newUser }, Cmd.none ) + + CreateUser -> + User.handleCreateUser model + + UserCreated result -> + User.handleUserCreated result model + + DeleteUser userId -> + User.handleDeleteUser userId model + + UserDeleted result -> + User.handleUserDeleted result model + + FetchUsers -> + case model.token of + Just token -> + ( model, Api.User.fetchUsers token ) + + Nothing -> + ( model, Cmd.none ) + + UsersReceived result -> + User.handleUsersReceived result model + + EditUserWorkHours userId -> + User.handleEditUserWorkHours userId model + + CancelEditUserWorkHours -> + ( { model + | editingUserId = Nothing + , editingUserWorkHours = "" + } + , Cmd.none + ) + + UpdateEditUserWorkHours hours -> + ( { model | editingUserWorkHours = hours }, Cmd.none ) + + SaveUserWorkHours -> + User.handleSaveUserWorkHours model + + UserWorkHoursSaved result -> + User.handleUserWorkHoursSaved result model + + ResetUserPassword userId -> + User.handleResetUserPassword userId model + + CancelResetPassword -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + } + , Cmd.none + ) + + UpdateResetPasswordNew password -> + ( { model | resetPasswordNew = password }, Cmd.none ) + + SaveResetPassword -> + User.handleSaveResetPassword model + + ResetPasswordSaved result -> + User.handleResetPasswordSaved result model + + UpdateUserWorkHours input -> + ( { model | userWorkHoursInput = input }, Cmd.none ) + + UpdateUserPassword input -> + ( { model | userPasswordInput = input }, Cmd.none ) + + SaveUserPassword -> + case ( model.token, model.selectedUserId ) of + ( Just token, Just userId ) -> + if String.length model.userPasswordInput > 0 then + ( model, Api.User.resetUserPassword token userId model.userPasswordInput ) + + else + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + UserPasswordSaved result -> + case result of + Ok _ -> + ( { model + | userPasswordInput = "" + , selectedUserId = Nothing + , error = Nothing + } + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) + ) + + Err err -> + ( model, Cmd.none ) + + SelectUserForManagement userId -> + ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) + + -- Time Entries + FetchMyTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchMyTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + MyTimeEntriesReceived result -> + TimeEntry.handleMyTimeEntriesReceived result model + + FetchAllTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchAllTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + AllTimeEntriesReceived result -> + TimeEntry.handleAllTimeEntriesReceived result model + + EditTimeEntry entryId -> + TimeEntry.handleEditTimeEntry entryId model + + CancelEditTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + } + , Cmd.none + ) + + UpdateEditTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditTimeEntry -> + TimeEntry.handleSaveEditTimeEntry model + + TimeEntrySaved result -> + TimeEntry.handleTimeEntrySaved result model + + TimeEntryDeleted result -> + TimeEntry.handleTimeEntryDeleted result model + + ConfirmDeleteTimeEntry entryId -> + TimeEntry.handleConfirmDeleteTimeEntry entryId model + + StartEditingTimeEntry entryId entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType + } + , Cmd.none + ) + + CancelEditingTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + } + , Cmd.none + ) + + UpdateEditingTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditingTimeEntry -> + case ( model.token, model.editingTimeEntryId ) of + ( Just token, Just entryId ) -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + _ -> + ( model, Cmd.none ) + + -- Weekly Hours + FetchWeeklyHours -> + case model.token of + Just token -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + WeeklyHoursReceived result -> + case result of + Ok hours -> + ( { model | weeklyHours = hours }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + MyWeeklySummaryReceived result -> + case result of + Ok summary -> + ( { model | userWeeklySummary = Just summary }, Cmd.none ) + + Err _ -> + ( { model | userWeeklySummary = Nothing }, Cmd.none ) + + -- Yearly Hours + FetchYearlyHoursSummary -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchYearlyHoursSummary token ) + + Nothing -> + ( model, Cmd.none ) + + YearlyHoursSummaryReceived result -> + TimeEntry.handleYearlyHoursSummaryReceived result model + + -- Admin Manual Entry + SelectUserForManualEntry userId -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) + + UpdateManualEntryDate date -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) + + UpdateManualEntryHours hours -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) + + UpdateManualEntryType entryType -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) + + SaveAdminTimeEntry -> + TimeEntry.handleSaveAdminTimeEntry model + + AdminTimeEntrySaved result -> + TimeEntry.handleAdminTimeEntrySaved result model + + -- My Info + FetchMyInfo -> + case model.token of + Just token -> + ( model, Api.User.fetchMyInfo token ) + + Nothing -> + ( model, Cmd.none ) + + MyInfoReceived result -> + case result of + Ok user -> + ( { model | users = [ user ] }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- School Years + FetchSchoolYears -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearsReceived result -> + SchoolYear.handleSchoolYearsReceived result model + + FetchActiveSchoolYear -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchActiveSchoolYear token ) + + Nothing -> + ( model, Cmd.none ) + + ActiveSchoolYearReceived result -> + SchoolYear.handleActiveSchoolYearReceived result model + + 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 -> + SchoolYear.handleCreateSchoolYear model + + SchoolYearCreated result -> + SchoolYear.handleSchoolYearCreated result model + + ActivateSchoolYear id -> + SchoolYear.handleActivateSchoolYear id model + + SchoolYearActivated result -> + SchoolYear.handleSchoolYearActivated result model + + DeleteSchoolYear id -> + SchoolYear.handleDeleteSchoolYear id model + + SchoolYearDeleted result -> + SchoolYear.handleSchoolYearDeleted result model + + -- PDF Download + DownloadYearlySummaryPDF -> + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token ) + + Nothing -> + ( model, Cmd.none ) + + YearlySummaryPDFReceived result -> + case result of + Ok pdfBytes -> + let + filename = + "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" + in + ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + -- Delete Confirmation + ConfirmDeleteUser userId -> + ( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) + + DeleteConfirmed confirmed -> + if confirmed then + case ( model.token, model.pendingDeleteId ) of + ( Just token, Just id ) -> + let + isTimeEntry = + List.any (\e -> e.id == id) model.timeEntries + in + if isTimeEntry then + ( model, Api.TimeEntry.deleteTimeEntry token id ) + + else + ( model, Api.User.deleteUser token id ) + + _ -> + ( model, Cmd.none ) + + else + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + -- Toasts + ShowToast message toastType -> + let + newToast = + { id = model.nextToastId + , message = message + , toastType = toastType + , dismissible = True + } + + dismissDelay = + case toastType of + ErrorToast -> + 8000 + + SuccessToast -> + 5000 + + InfoToast -> + 5000 + + WarningToast -> + 6000 + in + ( { model + | toasts = model.toasts ++ [ newToast ] + , nextToastId = model.nextToastId + 1 + } + , Task.perform (\_ -> AutoDismissToast newToast.id) + (Process.sleep dismissDelay) + ) + + DismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) + + AutoDismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) diff --git a/frontend/src/Update/UserUpdate.elm b/frontend/src/Update/UserUpdate.elm new file mode 100644 index 0000000..9fd4b85 --- /dev/null +++ b/frontend/src/Update/UserUpdate.elm @@ -0,0 +1,196 @@ +module Update.UserUpdate exposing + ( handleCreateUser + , handleDeleteUser + , handleEditUserWorkHours + , handleResetPasswordSaved + , handleResetUserPassword + , handleSaveResetPassword + , handleSaveUserWorkHours + , handleUserCreated + , handleUserDeleted + , handleUserWorkHoursSaved + , handleUsersReceived + ) + +import Api.User +import Http +import Task +import Types.Model exposing (Model, NewUser, ToastType(..), User) +import Types.Msg exposing (Msg(..)) + + +handleCreateUser : Model -> ( Model, Cmd Msg ) +handleCreateUser model = + case model.token of + Just token -> + ( model, Api.User.createUser token model.newUser ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserCreated result model = + case result of + Ok _ -> + let + emptyUser = + NewUser "" "" False + in + case model.token of + Just token -> + ( { model | newUser = emptyUser } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteUser : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteUser userId model = + case model.token of + Just token -> + ( model, Api.User.deleteUser token userId ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | pendingDeleteId = Nothing + , error = Nothing + , editingUserId = Nothing + , resetPasswordUserId = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg ) +handleUsersReceived result model = + case result of + Ok users -> + ( { model | users = users }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg ) +handleEditUserWorkHours userId model = + case List.filter (\u -> u.id == userId) model.users |> List.head of + Just user -> + ( { model + | editingUserId = Just userId + , editingUserWorkHours = String.fromFloat user.yearlyWorkHours + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveUserWorkHours : Model -> ( Model, Cmd Msg ) +handleSaveUserWorkHours model = + case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of + ( Just token, Just userId, Just hours ) -> + ( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) + + +handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserWorkHoursSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingUserWorkHours = "" + , editingUserId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg ) +handleResetUserPassword userId model = + ( { model + | resetPasswordUserId = Just userId + , resetPasswordNew = "" + } + , Cmd.none + ) + + +handleSaveResetPassword : Model -> ( Model, Cmd Msg ) +handleSaveResetPassword model = + case model.resetPasswordUserId of + Just userId -> + case model.token of + Just token -> + ( model, Api.User.resetUserPassword token userId model.resetPasswordNew ) + + Nothing -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + +handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleResetPasswordSaved result model = + case result of + Ok _ -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + , error = Nothing + } + , Cmd.batch + [ case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) + ] + ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Utils/DateUtils.elm b/frontend/src/Utils/DateUtils.elm new file mode 100644 index 0000000..1ea98dd --- /dev/null +++ b/frontend/src/Utils/DateUtils.elm @@ -0,0 +1,338 @@ +module Utils.DateUtils exposing + ( addDaysToDate + , getDateForWeekDay + , getDayOfWeek + , getDayOfYear + , getISOWeek + , getISOWeekFromPosix + , getWeekDateRange + , getYearWeekFromDate + , isLeapYear + , monthToInt + , nextWeek + , previousWeek + ) + +import Time + + +getISOWeekFromPosix : Time.Posix -> ( Int, Int ) +getISOWeekFromPosix time = + let + year = + Time.toYear Time.utc time + + month = + Time.toMonth Time.utc time |> monthToInt + + day = + Time.toDay Time.utc time + in + ( year, getISOWeek year month day ) + + +monthToInt : Time.Month -> Int +monthToInt month = + case month of + Time.Jan -> + 1 + + Time.Feb -> + 2 + + Time.Mar -> + 3 + + Time.Apr -> + 4 + + Time.May -> + 5 + + Time.Jun -> + 6 + + Time.Jul -> + 7 + + Time.Aug -> + 8 + + Time.Sep -> + 9 + + Time.Oct -> + 10 + + Time.Nov -> + 11 + + Time.Dec -> + 12 + + +getISOWeek : Int -> Int -> Int -> Int +getISOWeek year month day = + let + dayOfYear = + getDayOfYear year month day + + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1DayOfYear = + 4 - jan4DayOfWeek + + weekNum = + ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 + in + if weekNum < 1 then + 52 + + else if weekNum > 52 then + let + dec31DayOfWeek = + getDayOfWeek year 12 31 + + jan1DayOfWeek = + getDayOfWeek year 1 1 + in + if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then + weekNum + + else + 1 + + else + weekNum + + +getDayOfYear : Int -> Int -> Int -> Int +getDayOfYear year month day = + let + daysInMonth = + [ 31 + , if isLeapYear year then + 29 + + else + 28 + , 31 + , 30 + , 31 + , 30 + , 31 + , 31 + , 30 + , 31 + , 30 + , 31 + ] + + daysBefore = + List.take (month - 1) daysInMonth |> List.sum + in + daysBefore + day + + +isLeapYear : Int -> Bool +isLeapYear year = + (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) + + +getDayOfWeek : Int -> Int -> Int -> Int +getDayOfWeek year month day = + let + adjustedMonth = + if month < 3 then + month + 12 + + else + month + + adjustedYear = + if month < 3 then + year - 1 + + else + year + + q = + day + + m = + adjustedMonth + + k = + modBy 100 adjustedYear + + j = + adjustedYear // 100 + + h = + (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 + in + (h + 5) |> modBy 7 + + +getDateForWeekDay : Int -> Int -> Int -> String +getDateForWeekDay year week dayOfWeek = + let + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1Date = + 4 - jan4DayOfWeek + + targetDayOfYear = + mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek + + ( finalYear, finalMonth, finalDay ) = + if targetDayOfYear < 1 then + addDaysToDate (year - 1) 12 31 targetDayOfYear + + else + addDaysToDate year 1 targetDayOfYear 0 + in + String.fromInt finalYear + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalMonth) + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalDay) + + +addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) +addDaysToDate startYear startMonth startDay daysToAdd = + let + daysInMonth m y = + case m of + 1 -> + 31 + + 2 -> + if isLeapYear y then + 29 + + else + 28 + + 3 -> + 31 + + 4 -> + 30 + + 5 -> + 31 + + 6 -> + 30 + + 7 -> + 31 + + 8 -> + 31 + + 9 -> + 30 + + 10 -> + 31 + + 11 -> + 30 + + 12 -> + 31 + + _ -> + 0 + + helper y m d remaining = + if remaining == 0 then + ( y, m, d ) + + else if remaining > 0 then + let + daysInCurrentMonth = + daysInMonth m y + + daysLeftInMonth = + daysInCurrentMonth - d + in + if remaining <= daysLeftInMonth then + ( y, m, d + remaining ) + + else if m == 12 then + helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) + + else + helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) + + else if d + remaining >= 1 then + ( y, m, d + remaining ) + + else if m == 1 then + let + prevMonthDays = + daysInMonth 12 (y - 1) + in + helper (y - 1) 12 prevMonthDays (remaining + d) + + else + let + prevMonthDays = + daysInMonth (m - 1) y + in + helper y (m - 1) prevMonthDays (remaining + d) + in + helper startYear startMonth startDay daysToAdd + + +previousWeek : Int -> Int -> ( Int, Int ) +previousWeek year week = + if week == 1 then + ( year - 1, 52 ) + + else + ( year, week - 1 ) + + +nextWeek : Int -> Int -> ( Int, Int ) +nextWeek year week = + if week >= 52 then + ( year + 1, 1 ) + + else + ( year, week + 1 ) + + +getWeekDateRange : Int -> Int -> String +getWeekDateRange year week = + let + mondayDate = + getDateForWeekDay year week 0 + + fridayDate = + getDateForWeekDay year week 4 + in + mondayDate ++ " bis " ++ fridayDate + + +getYearWeekFromDate : String -> ( Int, Int ) +getYearWeekFromDate dateStr = + let + parts = + String.split "-" dateStr + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + in + ( year, getISOWeek year month day ) diff --git a/frontend/src/Utils/ErrorHandler.elm b/frontend/src/Utils/ErrorHandler.elm new file mode 100644 index 0000000..a9746e2 --- /dev/null +++ b/frontend/src/Utils/ErrorHandler.elm @@ -0,0 +1,42 @@ +module Utils.ErrorHandler exposing (handleApiError) + +import Api.Decoders exposing (apiErrorDecoder) +import Http +import Json.Decode as Decode +import Task +import Types.Model exposing (ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleApiError : Http.Error -> Cmd Msg +handleApiError error = + let + message = + case error of + Http.BadBody body -> + case Decode.decodeString apiErrorDecoder body of + Ok apiErr -> + apiErr.message + + Err _ -> + "Ein Fehler ist aufgetreten" + + Http.BadStatus 401 -> + "Keine Berechtigung - bitte erneut anmelden" + + Http.BadStatus 403 -> + "Zugriff verweigert" + + Http.BadStatus 404 -> + "Ressource nicht gefunden" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Ein unerwarteter Fehler ist aufgetreten" + in + Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Utils/Ports.elm b/frontend/src/Utils/Ports.elm new file mode 100644 index 0000000..f5b8dc2 --- /dev/null +++ b/frontend/src/Utils/Ports.elm @@ -0,0 +1,20 @@ +port module Utils.Ports exposing + ( confirmDelete + , confirmDeleteResponse + , removeToken + , saveToken + ) + +import Json.Encode as Encode + + +port saveToken : Encode.Value -> Cmd msg + + +port removeToken : () -> Cmd msg + + +port confirmDelete : String -> Cmd msg + + +port confirmDeleteResponse : (Bool -> msg) -> Sub msg diff --git a/frontend/src/Utils/TimeUtils.elm b/frontend/src/Utils/TimeUtils.elm new file mode 100644 index 0000000..2d74958 --- /dev/null +++ b/frontend/src/Utils/TimeUtils.elm @@ -0,0 +1,34 @@ +module Utils.TimeUtils exposing (calculateHours) + + +calculateHours : String -> String -> Float +calculateHours startTime endTime = + let + parseTime timeStr = + case String.split ":" timeStr of + [ h, m ] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + + _ -> + 0 + + start = + parseTime startTime + + end = + parseTime endTime + in + if end > start then + end - start + + else if endTime == "manual" then + case String.toFloat startTime of + Just time -> + time + + Nothing -> + 0 + + else + 0 diff --git a/frontend/src/View/AdminDashboard.elm b/frontend/src/View/AdminDashboard.elm new file mode 100644 index 0000000..9afcfb5 --- /dev/null +++ b/frontend/src/View/AdminDashboard.elm @@ -0,0 +1,1165 @@ +module View.AdminDashboard exposing (viewAdminDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, SchoolYear, TimeEntry, User, WeeklyHours, YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarAdmin" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] + [ 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 + ScheduleTab -> + viewScheduleTab model + + UsersTab -> + viewUsersTab model + + TimeEntriesTab -> + viewTimeEntriesTab model + + SchoolYearsTab -> + viewSchoolYearsTab model + ] + ] + ] + + +viewScheduleTab : Model -> Html Msg +viewScheduleTab model = + div [] + [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + ] + + +viewUsersTab : Model -> Html Msg +viewUsersTab model = + div [] + [ h2 [ class "title" ] [ text "Benutzer verwalten" ] + , viewUserForm model + , viewUserList model + ] + + +viewTimeEntriesTab : Model -> Html Msg +viewTimeEntriesTab model = + div [] + [ h2 [ class "title" ] [ text "Jahresübersicht" ] + , viewYearlyHoursSummary model + , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] + , viewAdminManualEntryForm model + , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] + , case model.editingTimeEntryId of + Just _ -> + viewTimeEntriesEditForm model + + Nothing -> + viewTimeEntriesListWithEdit model + ] + + +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" ] + ] + ] + + +viewScheduleList : Model -> Html Msg +viewScheduleList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Tag" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [] [ text "Titel" ] + , th [] [ text "Aktion" ] + ] + ] + , tbody [] + (List.map viewScheduleRow model.schedules) + ] + ] + + +viewScheduleForm : Model -> Html Msg +viewScheduleForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Wochentag" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleDay + , disabled model.isProcessing + , value model.newSchedule.dayOfWeek + ] + [ option [ value "" ] [ text "Wochentag wählen" ] + , option [ value "0" ] [ text "Montag" ] + , option [ value "1" ] [ text "Dienstag" ] + , option [ value "2" ] [ text "Mittwoch" ] + , option [ value "3" ] [ text "Donnerstag" ] + , option [ value "4" ] [ text "Freitag" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.startTime + , onInput UpdateNewScheduleStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.endTime + , onInput UpdateNewScheduleEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleType + , value model.newSchedule.scheduleType + , disabled model.isProcessing + ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Titel" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "z.B. Mathematik" + , value model.newSchedule.title + , onInput UpdateNewScheduleTitle + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchedule + , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Hinzufügen" + ] + ] + ] + , if String.isEmpty model.newSchedule.dayOfWeek then + div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] + + else + text "" + ] + + +viewScheduleRow : Schedule -> Html Msg +viewScheduleRow schedule = + let + dayName = + case schedule.dayOfWeek of + 0 -> + "Montag" + + 1 -> + "Dienstag" + + 2 -> + "Mittwoch" + + 3 -> + "Donnerstag" + + 4 -> + "Freitag" + + _ -> + "Unbekannt" + + typeName = + if schedule.scheduleType == "break" then + "Pause" + + else + "Unterricht" + in + tr [] + [ td [] [ text dayName ] + , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , td [] [ text typeName ] + , td [] [ text schedule.title ] + , td [] + [ button + [ class "button is-small is-danger" + , onClick (DeleteSchedule schedule.id) + ] + [ text "Löschen" ] + ] + ] + + +viewUserForm : Model -> Html Msg +viewUserForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.newUser.username + , onInput UpdateNewUsername + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.newUser.password + , onInput UpdateNewPassword + ] + [] + ] + ] + ] + , div [ class "column is-narrow" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Admin" ] + , div [ class "control" ] + [ label [ class "checkbox" ] + [ input + [ type_ "checkbox" + , checked model.newUser.isAdmin + , onCheck UpdateNewUserAdmin + ] + [] + , text " Admin-Rechte" + ] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] + ] + ] + ] + + +viewUserList : Model -> Html Msg +viewUserList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Benutzer" ] + , if List.isEmpty model.users then + p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "ID" ] + , th [] [ text "Benutzername" ] + , th [] [ text "Rolle" ] + , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewUserRowWithActions model) model.users) + ] + ] + + +viewUserRowWithActions : Model -> User -> Html Msg +viewUserRowWithActions model user = + if model.editingUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "number" + , step "0.5" + , value model.editingUserWorkHours + , onInput UpdateEditUserWorkHours + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] + ] + ] + + else if model.resetPasswordUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "password" + , placeholder "Neues Passwort" + , value model.resetPasswordNew + , onInput UpdateResetPasswordNew + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + div [] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditUserWorkHours user.id) + ] + [ text "Arbeitszeit" ] + , button + [ class "button is-small is-warning mr-2" + , onClick (ResetUserPassword user.id) + ] + [ text "PW Reset" ] + , button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewUserRow : User -> Html Msg +viewUserRow user = + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + + +viewTimeEntriesList : Model -> Html Msg +viewTimeEntriesList model = + let + filteredEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + in + div [ class "box" ] + [ if List.isEmpty filteredEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithActions model) filteredEntries) + ] + ] + + +viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithActions model entry = + let + hours = + if entry.entryType == "lesson" then + 1.0 + + else + calculateHours entry.startTime entry.endTime + in + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [] + [ div [ class "buttons are-small" ] + [ button + [ class "button is-info is-small" + , onClick (StartEditingTimeEntry entry.id entry) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-danger is-small" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewTimeEntriesEditForm : Model -> Html Msg +viewTimeEntriesEditForm model = + div [ class "box has-background-warning-light" ] + [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-success" + , onClick SaveEditTimeEntry + ] + [ text "Speichern" ] + ] + , div [ class "control" ] + [ button + [ class "button is-light" + , onClick CancelEditTimeEntry + ] + [ text "Abbrechen" ] + ] + ] + , viewTimeEntriesListWithEdit model + ] + + +viewTimeEntriesListWithEdit : Model -> Html Msg +viewTimeEntriesListWithEdit model = + div [ class "box" ] + [ if List.isEmpty model.timeEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) + ] + ] + + +viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithEdit model entry = + let + hours = + calculateHours entry.startTime entry.endTime + + isEditing = + model.editingTimeEntryId == Just entry.id + in + if isEditing then + tr [] + [ td [] [ text entry.username ] + , td [] + [ input + [ class "input is-small" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + , td [] + [ div [ class "field is-grouped" ] + [ div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + , div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , td [] + [ div [ class "select is-small" ] + [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + , td [ class "has-text-right" ] [ text "" ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditTimeEntry entry.id) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-small is-danger" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + + +viewWeeklyHoursSummary : Model -> Html Msg +viewWeeklyHoursSummary model = + let + filteredHours = + List.filter + (\h -> h.week == model.currentWeek && h.year == model.currentYear) + model.weeklyHours + in + div [ class "box" ] + [ if List.isEmpty filteredHours then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Arbeitet" ] + , th [ class "has-text-right" ] [ text "Soll" ] + , th [ class "has-text-right" ] [ text "Verbleibend" ] + , th [] [ text "Fortschritt" ] + ] + ] + , tbody [] + (List.map viewWeeklyHoursRow filteredHours) + , tfoot [] + [ tr [ class "has-background-light" ] + [ th [] [ text "Gesamt" ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] + , th [] [ text "" ] + , th [] [ text "" ] + ] + ] + ] + ] + + +viewWeeklyHoursRow : WeeklyHours -> Html Msg +viewWeeklyHoursRow hours = + let + progressPercent = + Basics.min 100 (hours.totalHours / hours.targetHours * 100) + + progressColor = + if hours.totalHours >= hours.targetHours then + "is-success" + + else if hours.totalHours >= hours.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + tr [] + [ td [] [ text hours.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] + , td [] + [ progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [] + ] + ] + + +viewAdminManualEntryForm : Model -> Html Msg +viewAdminManualEntryForm model = + div [ class "box has-background-info-light" ] + [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] + , p [ class "help mb-3" ] + [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Mitarbeiter" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] + (option [ value "" ] [ text "-- Wählen --" ] + :: List.map + (\u -> + option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] + ) + model.users + ) + ] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.adminManualEntryForm.date + , onInput UpdateManualEntryDate + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "number" + , step "0.5" + , placeholder "z.B. 2.5 oder -1.0" + , value model.adminManualEntryForm.hours + , onInput UpdateManualEntryHours + ] + [] + ] + , p [ class "help" ] + [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-info" + , onClick SaveAdminTimeEntry + , disabled + (case model.adminManualEntryForm.selectedUserId of + Just _ -> + model.isProcessing || String.isEmpty model.adminManualEntryForm.hours + + Nothing -> + True + ) + ] + [ text "Eintrag erstellen" ] + ] + ] + ] + + +viewYearlyHoursSummary : Model -> Html Msg +viewYearlyHoursSummary model = + div [ class "box" ] + [ div [ class "level mb-4" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ a + [ class "button is-info" + , onClick DownloadYearlySummaryPDF + , disabled model.isProcessing + ] + [ span [ class "icon" ] + [ i [ class "fas fa-file-pdf" ] [] ] + , span [] + [ text + (if model.isProcessing then + "Wird erstellt..." + + else + "PDF exportieren" + ) + ] + ] + ] + ] + ] + , if List.isEmpty model.yearlyHoursSummary then + p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] + , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] + , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] + , th [ class "has-text-centered" ] [ text "Status" ] + ] + ] + , tbody [] + (List.map viewYearlyHourRow model.yearlyHoursSummary) + ] + ] + + +viewYearlyHourRow : YearlyHoursSummary -> Html Msg +viewYearlyHourRow summary = + let + statusClass = + if summary.remainingYearly > 0 then + "has-text-danger" + + else if abs summary.remainingYearly < 0.5 then + "has-text-success" + + else + "has-text-warning" + in + tr [] + [ td [] [ text summary.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] + , td [ class ("has-text-centered " ++ statusClass) ] + [ if summary.remainingYearly > 0 then + text ("Offen: " ++ String.fromFloat summary.remainingYearly) + + else if summary.remainingYearly < -0.5 then + text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) + + else + text "✓ Erfüllt" + ] + ] diff --git a/frontend/src/View/Components/Navigation.elm b/frontend/src/View/Components/Navigation.elm new file mode 100644 index 0000000..ba3895d --- /dev/null +++ b/frontend/src/View/Components/Navigation.elm @@ -0,0 +1,99 @@ +module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewWeekNavigation : Model -> Html Msg +viewWeekNavigation model = + let + dateRange = + case model.weekDates of + Just wd -> + wd.range + + Nothing -> + "Laden..." + in + div [ class "box" ] + [ nav [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick PreviousWeek + ] + [ span [ class "icon" ] + [ i [ class "fas fa-chevron-left" ] [] ] + , span [] [ text "Vorherige Woche" ] + ] + ] + ] + , div [ class "level-item" ] + [ div + [ style "display" "flex" + , style "flex-direction" "column" + , style "align-items" "center" + , style "gap" "0.5rem" + , style "min-width" "250px" + ] + [ p + [ class "heading" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text "Kalenderwoche" ] + , p + [ class "title is-3" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] + , p + [ class "subtitle is-6" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text dateRange ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick NextWeek + ] + [ span [] [ text "Nächste Woche" ] + , span [ class "icon" ] + [ i [ class "fas fa-chevron-right" ] [] ] + ] + ] + ] + ] + ] + + +viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg +viewDayMobile model dayName ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + div [ class "box mb-4" ] + [ p [ class "has-text-weight-bold has-text-centered mb-3" ] + [ text (dayName ++ " - " ++ dateForDay) ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] diff --git a/frontend/src/View/Components/Schedule.elm b/frontend/src/View/Components/Schedule.elm new file mode 100644 index 0000000..57730bb --- /dev/null +++ b/frontend/src/View/Components/Schedule.elm @@ -0,0 +1,76 @@ +module View.Components.Schedule exposing (viewScheduleItemWithDay) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) + + +viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg +viewScheduleItemWithDay model dayOfWeek schedule = + let + isSelected = + List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries + + isClickable = + (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing + + boxClass = + if isSelected then + "box has-background-success-light" + + else if isClickable then + "box has-background-white" + + else + "box has-background-light" + + typeText = + if schedule.scheduleType == "break" then + " (Pause)" + + else + "" + + cursorStyle = + if isClickable then + "pointer" + + else + "not-allowed" + + opacity = + if isClickable || isSelected then + "1" + + else + "0.6" + in + div + [ class boxClass + , onClick + (if isClickable then + ToggleScheduleSelection schedule.id dayOfWeek + + else + FetchSchedules + ) + , style "cursor" cursorStyle + , style "margin-bottom" "0.5rem" + , style "padding" "0.75rem" + , style "opacity" opacity + , style "transition" "all 0.2s ease" + , style "border" + (if isClickable && not isSelected then + "2px solid transparent" + + else + "2px solid currentColor" + ) + ] + [ p [ class "has-text-weight-bold is-size-7" ] + [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [ class "is-size-7" ] + [ text (schedule.title ++ typeText) ] + ] diff --git a/frontend/src/View/Components/Toast.elm b/frontend/src/View/Components/Toast.elm new file mode 100644 index 0000000..e55d2fe --- /dev/null +++ b/frontend/src/View/Components/Toast.elm @@ -0,0 +1,66 @@ +module View.Components.Toast exposing (viewToasts) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, Toast, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewToasts : List Toast -> Html Msg +viewToasts toasts = + div [ class "toast-container" ] + (List.map viewToast toasts) + + +viewToast : Toast -> Html Msg +viewToast toast = + let + toastClass = + case toast.toastType of + ErrorToast -> + "toast-error" + + SuccessToast -> + "toast-success" + + InfoToast -> + "toast-info" + + WarningToast -> + "toast-warning" + + icon = + case toast.toastType of + ErrorToast -> + "fas fa-exclamation-circle" + + SuccessToast -> + "fas fa-check-circle" + + InfoToast -> + "fas fa-info-circle" + + WarningToast -> + "fas fa-exclamation-triangle" + in + div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] + [ div [ class "toast-content" ] + [ span [ class "toast-icon" ] + [ i [ class icon ] [] ] + , span [ class "toast-message" ] [ text toast.message ] + ] + , if toast.dismissible then + button + [ class "toast-close" + , onClick (DismissToast toast.id) + , attribute "aria-label" "Schließen" + ] + [ i [ class "fas fa-times" ] [] ] + + else + text "" + ] diff --git a/frontend/src/View/Login.elm b/frontend/src/View/Login.elm new file mode 100644 index 0000000..9ed2485 --- /dev/null +++ b/frontend/src/View/Login.elm @@ -0,0 +1,57 @@ +module View.Login exposing (viewLogin) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) + + +viewLogin : Model -> Html Msg +viewLogin model = + section [ class "section" ] + [ div [ class "container" ] + [ div [ class "columns is-centered" ] + [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] + [ div [ class "box" ] + [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.username + , onInput UpdateUsername + ] + [] + ] + ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.password + , onInput UpdatePassword + ] + [] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-fullwidth" + , onClick Login + ] + [ text "Anmelden" ] + ] + ] + ] + ] + ] + ] + ] diff --git a/frontend/src/View/UserDashboard.elm b/frontend/src/View/UserDashboard.elm new file mode 100644 index 0000000..60fac13 --- /dev/null +++ b/frontend/src/View/UserDashboard.elm @@ -0,0 +1,338 @@ +module View.UserDashboard exposing (viewUserDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewUserDashboard : Model -> Html Msg +viewUserDashboard model = + div [] + [ nav [ class "navbar is-primary" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "role" "navigation" + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarUser" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ viewWeekNavigation model + , h2 [ class "title" ] [ text "Stundenplan" ] + , if model.hasEntriesForCurrentWeek && not model.weekEditMode then + div [ class "notification is-success" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-check-circle" ] [] ] + , span [] [ text "Diese Woche wurde bereits erfasst" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-warning" + , onClick EnableEditMode + , disabled model.isProcessing + ] + [ text "Bearbeiten" ] + ] + ] + ] + ] + + else if model.weekEditMode then + div [ class "notification is-warning" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-edit" ] [] ] + , span [] [ text "Bearbeitungsmodus aktiv" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-danger is-small mr-2" + , onClick DeleteWeekEntries + , disabled model.isProcessing + ] + [ text "Einträge löschen" ] + , button + [ class "button is-light is-small" + , onClick DisableEditMode + ] + [ text "Abbrechen" ] + ] + ] + ] + ] + + else + div [ class "notification is-info is-light" ] + [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] + , viewScheduleGridWithWeek model + , if not model.hasEntriesForCurrentWeek || model.weekEditMode then + div [ class "field mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-large is-fullwidth" + , onClick SaveTimeEntries + , disabled (List.isEmpty model.selectedEntries || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text + (if model.weekEditMode then + "Änderungen speichern" + + else + "Speichern" + ) + ] + ] + ] + + else + text "" + , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] + , viewUserYearlyTotal model + ] + ] + ] + + +viewScheduleGridWithWeek : Model -> Html Msg +viewScheduleGridWithWeek model = + let + days = + [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] + + groupedSchedules = + List.range 0 4 + |> List.map + (\day -> + ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) + ) + in + div [] + [ div [ class "is-hidden-mobile" ] + [ div [ class "table-container" ] + [ table [ class "table is-bordered is-fullwidth" ] + [ thead [] + [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) + ] + , tbody [] + [ tr [] + (List.map (viewDayColumnWithWeek model) groupedSchedules) + ] + ] + ] + ] + , div [ class "is-hidden-tablet" ] + (List.map2 (viewDayMobile model) days groupedSchedules) + ] + + +viewUserYearlyTotal : Model -> Html Msg +viewUserYearlyTotal model = + let + yearlyTotal = + model.timeEntries + |> List.map + (\entry -> + if entry.entryType == "lesson" then + 1.0 + + else + Utils.TimeUtils.calculateHours entry.startTime entry.endTime + ) + |> List.sum + + userTarget = + List.filter (\u -> not u.isAdmin) model.users + |> List.head + |> Maybe.map .yearlyWorkHours + |> Maybe.withDefault 60 + + remaining = + userTarget - yearlyTotal + + progressPercent = + Basics.min 100 (yearlyTotal / userTarget * 100) + + progressColor = + if remaining <= 0 then + "is-success" + + else if yearlyTotal >= userTarget * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Jahresenziel" ] + , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Geleistete Stunden" ] + , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Restliche Stunden" ] + , p + [ class + ("title is-4 " + ++ (if remaining <= 0 then + "has-text-success" + + else + "has-text-warning" + ) + ) + ] + [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + +viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg +viewDayColumnWithWeek model ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] + [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] + [ text dateForDay ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] + + +viewUserWeeklySummary : Model -> Html Msg +viewUserWeeklySummary model = + case model.userWeeklySummary of + Just summary -> + let + progressPercent = + Basics.min 100 (summary.totalHours / summary.targetHours * 100) + + progressColor = + if summary.totalHours >= summary.targetHours then + "is-success" + + else if summary.totalHours >= summary.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] + , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] + , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Verbleibend" ] + , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] + [ text (String.fromFloat summary.remainingHours ++ " Std.") ] + , if summary.remainingHours < 0 then + p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] + + else + p [ class "subtitle is-6" ] [ text "" ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + Nothing -> + div [ class "box" ] + [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] + ] diff --git a/frontend/src/View/View.elm b/frontend/src/View/View.elm new file mode 100644 index 0000000..c16d910 --- /dev/null +++ b/frontend/src/View/View.elm @@ -0,0 +1,29 @@ +module View.View exposing (view) + +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import View.AdminDashboard exposing (viewAdminDashboard) +import View.Components.Toast exposing (viewToasts) +import View.Login exposing (viewLogin) +import View.UserDashboard exposing (viewUserDashboard) + + +view : Model -> Html Msg +view model = + div [ class "app-container" ] + [ viewToasts model.toasts + , div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model + + UserDashboard -> + viewUserDashboard model + + AdminDashboard -> + viewAdminDashboard model + ] + ]