port module Main exposing (..) import Browser import Dict exposing (Dict) 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 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 -- MAIN main : Program Flags Model Msg main = Browser.element { init = init , update = update , subscriptions = subscriptions , view = view } -- 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 } type Page = LoginPage | UserDashboard | AdminDashboard type AdminTab = ScheduleTab | UsersTab | TimeEntriesTab 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 , startTime : String , endTime : String , entryType : String } init : Flags -> ( Model, Cmd Msg ) init flags = let initialPage = case flags.token of Just _ -> if flags.isAdmin then AdminDashboard else UserDashboard Nothing -> LoginPage model = { page = initialPage , activeTab = ScheduleTab , username = "" , password = "" , token = flags.token , isAdmin = flags.isAdmin , schedules = [] , users = [] , timeEntries = [] , weeklyHours = [] , yearlyHoursSummary = [] , selectedEntries = [] , currentWeek = 1 , currentYear = 2025 , currentTime = Time.millisToPosix 0 , zone = Time.utc , newSchedule = NewSchedule "" "" "" "lesson" "" , newUser = NewUser "" "" False , error = Nothing , weekEditMode = False , hasEntriesForCurrentWeek = False , weekDates = Nothing , userWeeklySummary = Nothing , editingTimeEntryId = Nothing , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" , editingUserId = Nothing , editingUserWorkHours = "" , resetPasswordUserId = Nothing , resetPasswordNew = "" , pendingDeleteId = Nothing , selectedUserId = Nothing , userWorkHoursInput = "" , userPasswordInput = "" , isProcessing = False , mobileMenuOpen = False , adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson" } cmd = case flags.token of Just token -> Cmd.batch [ Task.perform SetTime Time.now , fetchSchedules (Just token) , fetchYearlyHoursSummary token , if flags.isAdmin then Cmd.none else fetchMyInfo token ] Nothing -> Task.perform SetTime Time.now in ( model, cmd ) -- 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 | UpdateManualEntryStartTime String | UpdateManualEntryEndTime String | UpdateManualEntryType String | SaveAdminTimeEntry | AdminTimeEntrySaved (Result Http.Error ()) | FetchMyInfo | MyInfoReceived (Result Http.Error User) 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) , 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 _) -> ( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) 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 _) -> ( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) 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 ] ) Nothing -> ( model, Cmd.none ) TimeEntriesSaved (Err _) -> ( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) 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 _) -> ( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) 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 _) -> ( model, Cmd.none ) 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 } , fetchMyTimeEntries token ) Nothing -> ( model, Cmd.none ) WeekEntriesDeleted (Err _) -> ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) 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 _ -> 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 | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) 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 } , fetchSchedules model.token ) Nothing -> ( model, Cmd.none ) ScheduleCreated (Err err) -> let errorMsg = case err of Http.BadStatus 400 -> "Ungültige Eingabe" Http.BadStatus 409 -> "Dieser Stundenplan existiert bereits" Http.Timeout -> "Anfrage abgelaufen" Http.NetworkError -> "Netzwerkfehler" _ -> "Fehler beim Erstellen" in ( { model | error = Just errorMsg , isProcessing = False } , Cmd.none ) 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 }, fetchSchedules (Just token) ) Nothing -> ( model, Cmd.none ) ScheduleDeleted (Err _) -> ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) 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 }, fetchUsers token ) Nothing -> ( model, Cmd.none ) UserCreated (Err _) -> ( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) 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 } , fetchUsers token ) Nothing -> ( model, Cmd.none ) UserDeleted (Err _) -> ( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) FetchUsers -> case model.token of Just token -> ( model, fetchUsers token ) Nothing -> ( model, Cmd.none ) UsersReceived (Ok users) -> ( { model | users = users }, Cmd.none ) UsersReceived (Err _) -> ( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) 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 _) -> ( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) FetchAllTimeEntries -> case model.token of Just token -> ( model, fetchAllTimeEntries token ) Nothing -> ( model, Cmd.none ) AllTimeEntriesReceived (Ok entries) -> ( { model | timeEntries = entries }, Cmd.none ) AllTimeEntriesReceived (Err _) -> ( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) FetchWeeklyHours -> case model.token of Just token -> ( model, fetchWeeklyHours token ) Nothing -> ( model, Cmd.none ) WeeklyHoursReceived (Ok hours) -> ( { model | weeklyHours = hours }, Cmd.none ) WeeklyHoursReceived (Err _) -> ( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) FetchYearlyHoursSummary -> case model.token of Just token -> ( model, fetchYearlyHoursSummary token ) Nothing -> ( model, Cmd.none ) YearlyHoursSummaryReceived (Ok summary) -> ( { model | yearlyHoursSummary = summary }, Cmd.none ) YearlyHoursSummaryReceived (Err _) -> ( { model | error = Just "Fehler beim Laden der Jahresübersicht" }, Cmd.none ) 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 , fetchWeeklyHours token , fetchYearlyHoursSummary token ] ) Nothing -> ( model, Cmd.none ) TimeEntryDeleted (Err _) -> ( { model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing }, Cmd.none ) 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 = Just "Passwort erfolgreich zurückgesetzt" } , case model.token of Just token -> fetchUsers token Nothing -> Cmd.none ) ResetPasswordSaved (Err _) -> ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) 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 } , fetchAllTimeEntries token ) Nothing -> ( model, Cmd.none ) TimeEntrySaved (Err _) -> ( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) 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 | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none ) UserWorkHoursSaved (Ok _) -> case model.token of Just token -> ( { model | editingUserWorkHours = "" , editingUserId = Nothing , error = Nothing } , fetchUsers token ) Nothing -> ( model, Cmd.none ) UserWorkHoursSaved (Err _) -> ( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, 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, resetUserPassword token userId model.userPasswordInput ) else ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) _ -> ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) UserPasswordSaved (Ok _) -> ( { model | userPasswordInput = "" , selectedUserId = Nothing , error = Nothing } , Cmd.none ) UserPasswordSaved (Err _) -> ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) 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 ) UpdateManualEntryStartTime time -> let form = model.adminManualEntryForm in ( { model | adminManualEntryForm = { form | startTime = time } }, Cmd.none ) UpdateManualEntryEndTime time -> let form = model.adminManualEntryForm in ( { model | adminManualEntryForm = { form | endTime = time } }, 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 "" "" "" "lesson" , error = Nothing , isProcessing = False } , Cmd.batch [ fetchAllTimeEntries token , fetchYearlyHoursSummary token , fetchWeeklyHours token ] ) Nothing -> ( model, Cmd.none ) AdminTimeEntrySaved (Err _) -> ( { model | error = Just "Fehler beim Erstellen des Eintrags", isProcessing = False }, Cmd.none ) FetchMyInfo -> case model.token of Just token -> ( model, fetchMyInfo token ) Nothing -> ( model, Cmd.none ) MyInfoReceived (Ok user) -> ( { model | users = [ user ] }, Cmd.none ) MyInfoReceived (Err _) -> ( { model | error = Just "Fehler beim Laden deiner Daten" }, 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 0 -- VIEW view : Model -> Html Msg view model = 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" ] , case model.error of Just err -> div [ class "notification is-danger" ] [ text err ] Nothing -> text "" , 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 , case model.error of Just err -> div [ class "notification is-danger mt-4" ] [ text err ] Nothing -> text "" ] ] ] 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" ] ] ] ] , case model.activeTab of ScheduleTab -> viewScheduleTab model UsersTab -> viewUsersTab model TimeEntriesTab -> viewTimeEntriesTab 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 1800 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" ] [ 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 "Neuer Zeiteintrag" ] , div [ class "columns" ] [ div [ class "column" ] [ 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 [] [ text "-- Wählen --" ] :: List.map (\user -> option [ value (String.fromInt user.id) ] [ text user.username ]) (List.filter (\u -> not u.isAdmin) model.users) ) ] ] ] ] , div [ class "column" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Datum" ] , div [ class "control" ] [ input [ class "input" , type_ "date" , value model.adminManualEntryForm.date , onInput UpdateManualEntryDate ] [] ] ] ] ] , div [ class "columns" ] [ div [ class "column" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Startzeit" ] , div [ class "control" ] [ input [ class "input" , type_ "time" , value model.adminManualEntryForm.startTime , onInput UpdateManualEntryStartTime ] [] ] ] ] , div [ class "column" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Endzeit" ] , div [ class "control" ] [ input [ class "input" , type_ "time" , value model.adminManualEntryForm.endTime , onInput UpdateManualEntryEndTime ] [] ] ] ] , div [ class "column" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Typ" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] [ select [ onInput UpdateManualEntryType, value model.adminManualEntryForm.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-info" , onClick SaveAdminTimeEntry , disabled (case model.adminManualEntryForm.selectedUserId of Just _ -> model.isProcessing 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 ] [ text "← Vorherige Woche" ] ] ] , div [ class "level-item has-text-centered" ] [ div [] [ p [ class "heading" ] [ text "Kalenderwoche" ] , p [ class "title" ] [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] , p [ class "subtitle is-6" ] [ text dateRange ] ] ] , div [ class "level-right" ] [ div [ class "level-item" ] [ button [ class "button is-primary" , onClick NextWeek ] [ text "Nächste Woche →" ] ] ] ] ] 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" ] ] ] ] -- 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 ) , ( "start_time", Encode.string entry.startTime ) , ( "end_time", Encode.string entry.endTime ) , ( "type", Encode.string entry.entryType ) ] , 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 }