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 + ] + ]