Merge branch 'improve-frontend-maintanability-by-splitting-in-sperate-files'

* improve-frontend-maintanability-by-splitting-in-sperate-files:
  docs: update Readme
  fix: fix while deleting timeentries for whole week
This commit is contained in:
Patryk Hegenberg 2025-11-09 23:26:12 +01:00
commit 3c61c1cb2c
31 changed files with 5042 additions and 4398 deletions

View file

@ -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.4.1
**Version**: 1.5.0
**Letztes Update**: November 2025
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter

View file

@ -608,3 +608,19 @@ func DeleteSchoolYear(db *sql.DB, id int) error {
return nil
}
func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error {
dates := calculateWeekDates(year, week)
var dateList []string
for day := 0; day <= 4; day++ {
dateList = append(dateList, dates.Dates[fmt.Sprint(day)])
}
query := `DELETE FROM time_entries
WHERE user_id = ?
AND type != 'manual'
AND date IN (?, ?, ?, ?, ?)`
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
return err
}

View file

@ -340,7 +340,7 @@ func (app *App) DeleteWeekEntries(c echo.Context) error {
return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
@ -417,6 +417,19 @@ func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
return HandleError(c, ErrMissingFieldMsg("Zeiteinträge"))
}
if len(req.Entries) > 0 {
firstDate := req.Entries[0].Date
t, err := time.Parse("2006-01-02", firstDate)
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Datum-Format"))
}
year, week := t.ISOWeek()
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
}
tx, err := app.DB.Begin()
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))

21
frontend/src/Api/Auth.elm Normal file
View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

110
frontend/src/Api/User.elm Normal file
View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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
}

133
frontend/src/Types/Msg.elm Normal file
View file

@ -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

View file

@ -0,0 +1,17 @@
module Types.Page exposing
( AdminTab(..)
, Page(..)
)
type Page
= LoginPage
| UserDashboard
| AdminDashboard
type AdminTab
= ScheduleTab
| UsersTab
| TimeEntriesTab
| SchoolYearsTab

View file

@ -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 ()
)

View file

@ -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 )

View file

@ -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 )

View file

@ -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 )

View file

@ -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
)

View file

@ -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 )

View file

@ -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 )

View file

@ -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 ())

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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)
]

View file

@ -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) ]
]

View file

@ -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 ""
]

View file

@ -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" ]
]
]
]
]
]
]
]

View file

@ -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..." ]
]

View file

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