diff options
Diffstat (limited to 'src/client/LoggedIn')
24 files changed, 1436 insertions, 0 deletions
diff --git a/src/client/LoggedIn/Category/Model.elm b/src/client/LoggedIn/Category/Model.elm new file mode 100644 index 0000000..7092fc4 --- /dev/null +++ b/src/client/LoggedIn/Category/Model.elm @@ -0,0 +1,36 @@ +module LoggedIn.Category.Model exposing + ( Model + , AddCategory + , init + , initForm + , validation + ) + +import Date exposing (Date) + +import Form exposing (Form) +import Form.Validate as Validate exposing (Validation) +import Validation + +type alias Model = + { addCategory : Form String AddCategory + } + +type alias AddCategory = + { amount : Int + , date : Date + } + +init : Model +init = + { addCategory = initForm + } + +initForm : Form String AddCategory +initForm = Form.initial [] validation + +validation : Validation String AddCategory +validation = + Validate.map2 AddCategory + (Validate.field "amount" (Validate.int |> Validate.andThen (Validate.minInt 1))) + (Validate.field "date" Validation.date) diff --git a/src/client/LoggedIn/Category/Msg.elm b/src/client/LoggedIn/Category/Msg.elm new file mode 100644 index 0000000..3184297 --- /dev/null +++ b/src/client/LoggedIn/Category/Msg.elm @@ -0,0 +1,9 @@ +module LoggedIn.Category.Msg exposing + ( Msg(..) + ) + +import Form exposing (Form) + +type Msg = + NoOp + | AddCategoryMsg Form.Msg diff --git a/src/client/LoggedIn/Category/Table/View.elm b/src/client/LoggedIn/Category/Table/View.elm new file mode 100644 index 0000000..fa7a7b1 --- /dev/null +++ b/src/client/LoggedIn/Category/Table/View.elm @@ -0,0 +1,124 @@ +module LoggedIn.Category.Table.View exposing + ( view + ) + +import Dict exposing (..) +import Date exposing (Date) +import String exposing (append) + +import FontAwesome +import View.Color as Color + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) + +import Dialog +import Dialog.AddCategory.Model as AddCategory +import Dialog.AddCategory.View as AddCategory + +import Tooltip + +import Msg exposing (Msg) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Msg as LoggedInMsg + +import LoggedIn.Category.Model as Category +import View.Date as Date +import LoggedIn.View.Format as Format + +import Model.User exposing (getUserName) +import Model.Category as Category exposing (CategoryId, Category) +import Model.PaymentCategory as PaymentCategory +import Model.Translations exposing (getMessage) + +view : LoggedData -> Category.Model -> Html Msg +view loggedData categoryModel = + let categories = + loggedData.categories + |> Dict.toList + |> List.sortBy (.name << Tuple.second) + in div + [ class "table" ] + [ div + [ class "lines" ] + ( headerLine loggedData :: List.map (paymentLine loggedData categoryModel) categories) + , if List.isEmpty (Dict.toList loggedData.categories) + then + div + [ class "emptyTableMsg" ] + [ text <| getMessage loggedData.translations "NoCategories" ] + else + text "" + ] + +headerLine : LoggedData -> Html Msg +headerLine loggedData = + div + [ class "header" ] + [ div [ class "cell name" ] [ text <| getMessage loggedData.translations "Name" ] + , div [ class "cell category" ] [ text <| getMessage loggedData.translations "Color" ] + , div [ class "cell" ] [] + , div [ class "cell" ] [] + , div [ class "cell" ] [] + ] + +paymentLine : LoggedData -> Category.Model -> (CategoryId, Category) -> Html Msg +paymentLine loggedData categoryModel (categoryId, category) = + div + [ class "row" ] + [ div + [ class "cell category" ] + [ text category.name ] + , div + [ class "cell category" ] + [ span + [ class "tag" + , style [("background-color", category.color)] + ] + [ text category.color ] + ] + , div + [ class "cell button" ] + [ let currentDate = Date.fromTime loggedData.currentTime + in AddCategory.button + loggedData + (AddCategory.initialClone loggedData.translations category) + "CloneCategory" + (FontAwesome.clone Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Clone")) + ] + , div + [ class "cell button" ] + [ AddCategory.button + loggedData + (AddCategory.initialEdit loggedData.translations categoryId category) + "EditCategory" + (FontAwesome.pencil Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Edit")) + ] + , div + [ class "cell button" ] + [ if PaymentCategory.isCategoryUnused categoryId loggedData.paymentCategories + then + let dialogConfig = + { className = "deleteCategoryDialog" + , title = getMessage loggedData.translations "ConfirmCategoryDelete" + , body = always <| text "" + , confirm = getMessage loggedData.translations "Confirm" + , confirmMsg = always <| Msg.Dialog <| Dialog.UpdateAndClose <| Msg.DeleteCategory categoryId + , undo = getMessage loggedData.translations "Undo" + } + in button + ( Tooltip.show Msg.Tooltip (getMessage loggedData.translations "Delete") + ++ [ onClick (Msg.Dialog <| Dialog.Open dialogConfig) ] + ) + [ FontAwesome.trash Color.chestnutRose 18 ] + else + span + ( Tooltip.show Msg.Tooltip (getMessage loggedData.translations "UsedCategory") ) + [ FontAwesome.trash Color.silver 18 ] + ] + ] diff --git a/src/client/LoggedIn/Category/Update.elm b/src/client/LoggedIn/Category/Update.elm new file mode 100644 index 0000000..1072ef0 --- /dev/null +++ b/src/client/LoggedIn/Category/Update.elm @@ -0,0 +1,24 @@ +module LoggedIn.Category.Update exposing + ( update + ) + +import Form exposing (Form) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Category.Model as Category +import LoggedIn.Category.Msg as Category + +update : LoggedData -> Category.Msg -> Category.Model -> (Category.Model, Cmd Category.Msg) +update loggedData msg model = + case msg of + + Category.NoOp -> + ( model + , Cmd.none + ) + + Category.AddCategoryMsg formMsg -> + ( { model | addCategory = Form.update Category.validation formMsg model.addCategory } + , Cmd.none + ) diff --git a/src/client/LoggedIn/Category/View.elm b/src/client/LoggedIn/Category/View.elm new file mode 100644 index 0000000..4e04fa2 --- /dev/null +++ b/src/client/LoggedIn/Category/View.elm @@ -0,0 +1,35 @@ +module LoggedIn.Category.View exposing + ( view + ) + +import Html exposing (..) +import Html.Attributes exposing (..) + +import LoggedData exposing (LoggedData) + +import Msg exposing (Msg) + +import Dialog.AddCategory.Model as AddCategory +import Dialog.AddCategory.View as AddCategory + +import LoggedIn.Category.Model as Category +import LoggedIn.Category.Table.View as Table + +import Model.Translations exposing (getMessage, getParamMessage) + +view : LoggedData -> Category.Model -> Html Msg +view loggedData categoryModel = + div + [ class "categories" ] + [ div + [ class "titleButton withMargin" ] + [ h1 [] [ text <| getMessage loggedData.translations "Categories" ] + , AddCategory.button + loggedData + (AddCategory.initialAdd loggedData.translations) + "AddCategory" + (text (getMessage loggedData.translations "AddCategory")) + Nothing + ] + , Table.view loggedData categoryModel + ] diff --git a/src/client/LoggedIn/Home/Header/View.elm b/src/client/LoggedIn/Home/Header/View.elm new file mode 100644 index 0000000..3f8a320 --- /dev/null +++ b/src/client/LoggedIn/Home/Header/View.elm @@ -0,0 +1,104 @@ +module LoggedIn.Home.Header.View exposing + ( view + ) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import String +import Dict +import Date + +import Form exposing (Form) +import View.Form as Form +import View.Events exposing (onSubmitPrevDefault) + +import Msg exposing (Msg) +import LoggedIn.Msg as LoggedInMsg +import LoggedIn.Home.Msg as HomeMsg + +import LoggedData exposing (LoggedData) +import LoggedIn.Home.Model as Home +import Model.Translations exposing (getParamMessage) +import Model.Conf exposing (Conf) +import Model.Payment as Payment exposing (Payments, Frequency(..)) +import Model.Translations exposing (getMessage) + +import Dialog.AddPayment.Model as AddPayment +import Dialog.AddPayment.View as AddPayment + +import LoggedIn.Home.View.ExceedingPayers as ExceedingPayers +import LoggedIn.View.Format as Format +import View.Plural exposing (plural) + +view : LoggedData -> Home.Model -> Payments -> Frequency -> Html Msg +view loggedData { search } payments frequency = + let currentDate = Date.fromTime loggedData.currentTime + in Html.div + [ class "header" ] + [ div + [ class "payerAndAdd" ] + [ ExceedingPayers.view loggedData + , AddPayment.button + loggedData + (AddPayment.initialAdd loggedData.translations currentDate frequency) + "AddPayment" + (text (getMessage loggedData.translations "AddPayment")) + Nothing + ] + , Html.div + [ class "searchLine" ] + [ searchForm loggedData search ] + , infos loggedData payments + ] + +searchForm : LoggedData -> Form String Home.Search -> Html Msg +searchForm loggedData search = + Html.map (Msg.UpdateLoggedIn << LoggedInMsg.HomeMsg << HomeMsg.SearchMsg) <| + Html.form + [ onSubmitPrevDefault Form.NoOp ] + [ Form.textInput loggedData.translations search "search" "name" + , if List.isEmpty (Payment.monthly loggedData.payments) + then text "" + else Form.radioInputs loggedData.translations search "search" "frequency" [ toString Punctual, toString Monthly ] + ] + +infos : LoggedData -> Payments -> Html Msg +infos loggedData payments = + let paymentsCount = List.length payments + in if paymentsCount == 0 + then text "" + else + let count = plural loggedData.translations (List.length payments) "Payment" "Payments" + sum = paymentsSum loggedData.conf payments + in div + [ class "infos" ] + [ span + [ class "total" ] + [ text <| getParamMessage [ count, sum ] loggedData.translations "Worth" ] + , span + [ class "partition" ] + [ text <| paymentsPartition loggedData payments ] + ] + +paymentsPartition : LoggedData -> Payments -> String +paymentsPartition loggedData payments = + String.join + ", " + ( loggedData.users + |> Dict.toList + |> List.map (Tuple.mapFirst (\userId -> Payment.totalPayments (always True) userId payments)) + |> List.filter (\(sum, _) -> sum > 0) + |> List.sortBy Tuple.first + |> List.reverse + |> List.map (\(sum, user) -> + getParamMessage [ user.name, Format.price loggedData.conf sum ] loggedData.translations "By" + ) + ) + +paymentsSum : Conf -> Payments -> String +paymentsSum conf payments = + payments + |> List.map .cost + |> List.sum + |> Format.price conf diff --git a/src/client/LoggedIn/Home/Model.elm b/src/client/LoggedIn/Home/Model.elm new file mode 100644 index 0000000..ace1593 --- /dev/null +++ b/src/client/LoggedIn/Home/Model.elm @@ -0,0 +1,40 @@ +module LoggedIn.Home.Model exposing + ( Model + , Search + , init + , searchInitial + , validation + ) + +import Form exposing (Form) +import Form.Validate as Validate exposing (Validation) +import Form.Field as Field exposing (Field) + +import Model.User exposing (Users, UserId) +import Model.Payment as Payment exposing (PaymentId, Payments, Frequency(..)) +import Model.Payer exposing (Payers) + +type alias Model = + { currentPage : Int + , search : Form String Search + } + +type alias Search = + { name : Maybe String + , frequency : Frequency + } + +init : Model +init = + { currentPage = 1 + , search = Form.initial (searchInitial Punctual) validation + } + +searchInitial : Frequency -> List (String, Field) +searchInitial frequency = [ ("frequency", Field.string (toString frequency)) ] + +validation : Validation String Search +validation = + Validate.map2 Search + (Validate.field "name" (Validate.maybe Validate.string)) + (Validate.field "frequency" Payment.validateFrequency) diff --git a/src/client/LoggedIn/Home/Msg.elm b/src/client/LoggedIn/Home/Msg.elm new file mode 100644 index 0000000..b5f2566 --- /dev/null +++ b/src/client/LoggedIn/Home/Msg.elm @@ -0,0 +1,12 @@ +module LoggedIn.Home.Msg exposing + ( Msg(..) + ) + +import Form exposing (Form) + +import Model.Payment exposing (PaymentId) + +type Msg = + NoOp + | UpdatePage Int + | SearchMsg Form.Msg diff --git a/src/client/LoggedIn/Home/Update.elm b/src/client/LoggedIn/Home/Update.elm new file mode 100644 index 0000000..b0ce256 --- /dev/null +++ b/src/client/LoggedIn/Home/Update.elm @@ -0,0 +1,35 @@ +module LoggedIn.Home.Update exposing + ( update + ) + +import Form exposing (Form) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Home.Msg as Home +import LoggedIn.Home.Model as Home + +update : LoggedData -> Home.Msg -> Home.Model -> (Home.Model, Cmd Home.Msg) +update loggedData msg model = + case msg of + + Home.NoOp -> + ( model + , Cmd.none + ) + + Home.UpdatePage page -> + ( { model | currentPage = page } + , Cmd.none + ) + + Home.SearchMsg formMsg -> + ( { model + | search = Form.update Home.validation formMsg model.search + , currentPage = + case formMsg of + Form.Input "name" _ _ -> 1 + _ -> model.currentPage + } + , Cmd.none + ) diff --git a/src/client/LoggedIn/Home/View.elm b/src/client/LoggedIn/Home/View.elm new file mode 100644 index 0000000..0b90e67 --- /dev/null +++ b/src/client/LoggedIn/Home/View.elm @@ -0,0 +1,38 @@ +module LoggedIn.Home.View exposing + ( view + ) + +import Date +import Html exposing (..) +import Html.Attributes exposing (..) + +import Form +import Utils.Form as Form + +import LoggedData exposing (LoggedData) +import LoggedIn.Home.Header.View as Header +import LoggedIn.Home.Model as Home +import LoggedIn.Home.Msg as HomeMsg +import LoggedIn.Home.View.Paging as Paging +import LoggedIn.Home.View.Table as Table +import LoggedIn.Msg as LoggedInMsg +import Model.Payment as Payment exposing (Frequency(..)) +import Msg exposing (Msg) + +view : LoggedData -> Home.Model -> Html Msg +view loggedData home = + let (name, frequency) = + case Form.getOutput home.search of + Just data -> (Maybe.withDefault "" data.name, data.frequency) + Nothing -> ("", Punctual) + payments = Payment.search name frequency loggedData.payments + in div + [ class "home" ] + [ Header.view loggedData home payments frequency + , Table.view loggedData home payments frequency + , Paging.view + home.currentPage + (List.length payments) + Msg.NoOp + (Msg.UpdateLoggedIn << LoggedInMsg.HomeMsg << HomeMsg.UpdatePage) + ] diff --git a/src/client/LoggedIn/Home/View/ExceedingPayers.elm b/src/client/LoggedIn/Home/View/ExceedingPayers.elm new file mode 100644 index 0000000..6f2439c --- /dev/null +++ b/src/client/LoggedIn/Home/View/ExceedingPayers.elm @@ -0,0 +1,45 @@ +module LoggedIn.Home.View.ExceedingPayers exposing + ( view + ) + +import Html exposing (..) +import Html.Attributes exposing (..) + +import Msg exposing (Msg) + +import LoggedData exposing (LoggedData) + +import LoggedIn.View.Format as Format + +import Model exposing (Model) +import Model.User exposing (getUserName) +import Model.Payment as Payment +import Model.Payer exposing (..) +import Model.Translations exposing (getMessage) + +view : LoggedData -> Html Msg +view loggedData = + let payments = Payment.punctual loggedData.payments + exceedingPayers = getOrderedExceedingPayers loggedData.currentTime loggedData.users loggedData.incomes payments + in div + [ class "exceedingPayers" ] + ( if List.isEmpty exceedingPayers + then [ text <| getMessage loggedData.translations "PaymentsAreBalanced" ] + else (List.map (exceedingPayer loggedData) exceedingPayers) + ) + +exceedingPayer : LoggedData -> ExceedingPayer -> Html Msg +exceedingPayer loggedData payer = + span + [ class "exceedingPayer" ] + [ span + [ class "userName" ] + [ payer.userId + |> getUserName loggedData.users + |> Maybe.withDefault "−" + |> text + ] + , span + [ class "amount" ] + [ text ("+ " ++ (Format.price loggedData.conf payer.amount)) ] + ] diff --git a/src/client/LoggedIn/Home/View/Paging.elm b/src/client/LoggedIn/Home/View/Paging.elm new file mode 100644 index 0000000..dffe061 --- /dev/null +++ b/src/client/LoggedIn/Home/View/Paging.elm @@ -0,0 +1,109 @@ +module LoggedIn.Home.View.Paging exposing + ( view + ) + +import Color exposing (Color) + +import FontAwesome + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) + +import LoggedData exposing (LoggedData) +import Model.Payment as Payment exposing (Payments, perPage) + +showedPages : Int +showedPages = 5 + +view : Int -> Int -> msg -> (Int -> msg) -> Html msg +view currentPage payments noOp pageMsg = + let maxPage = ceiling (toFloat payments / toFloat perPage) + pages = truncatePages currentPage (List.range 1 maxPage) + in if maxPage <= 1 + then + text "" + else + div + [ class "pages" ] + ( [ firstPage currentPage pageMsg + , previousPage currentPage noOp pageMsg + ] + ++ ( List.map (paymentsPage currentPage noOp pageMsg) pages) + ++ [ nextPage currentPage maxPage noOp pageMsg + , lastPage currentPage maxPage pageMsg + ] + ) + +truncatePages : Int -> List Int -> List Int +truncatePages currentPage pages = + let totalPages = List.length pages + showedLeftPages = ceiling ((toFloat showedPages - 1) / 2) + showedRightPages = floor ((toFloat showedPages - 1) / 2) + truncatedPages = + if currentPage <= showedLeftPages then + (List.range 1 showedPages) + else if currentPage > totalPages - showedRightPages then + (List.range (totalPages - showedPages + 1) totalPages) + else + (List.range (currentPage - showedLeftPages) (currentPage + showedRightPages)) + in List.filter (flip List.member pages) truncatedPages + +firstPage : Int -> (Int -> msg) -> Html msg +firstPage currentPage pageMsg = + button + [ classList + [ ("page", True) + , ("disable", currentPage <= 1) + ] + , onClick (pageMsg 1) + ] + [ FontAwesome.fast_backward grey 13 ] + +previousPage : Int -> msg -> (Int -> msg) -> Html msg +previousPage currentPage noOp pageMsg = + button + [ class "page" + , onClick <| + if currentPage > 1 + then (pageMsg <| currentPage - 1) + else noOp + ] + [ FontAwesome.backward grey 13 ] + +nextPage : Int -> Int -> msg -> (Int -> msg) -> Html msg +nextPage currentPage maxPage noOp pageMsg = + button + [ class "page" + , onClick <| + if currentPage < maxPage + then (pageMsg <| currentPage + 1) + else noOp + ] + [ FontAwesome.forward grey 13 ] + +lastPage : Int -> Int -> (Int -> msg) -> Html msg +lastPage currentPage maxPage pageMsg = + button + [ class "page" + , onClick (pageMsg maxPage) + ] + [ FontAwesome.fast_forward grey 13 ] + +paymentsPage : Int -> msg -> (Int -> msg) -> Int -> Html msg +paymentsPage currentPage noOp pageMsg page = + let onCurrentPage = page == currentPage + in button + [ classList + [ ("page", True) + , ("current", onCurrentPage) + ] + , onClick <| + if onCurrentPage + then noOp + else pageMsg page + ] + [ text (toString page) ] + +grey : Color +grey = Color.greyscale 0.35 diff --git a/src/client/LoggedIn/Home/View/Table.elm b/src/client/LoggedIn/Home/View/Table.elm new file mode 100644 index 0000000..8828488 --- /dev/null +++ b/src/client/LoggedIn/Home/View/Table.elm @@ -0,0 +1,166 @@ +module LoggedIn.Home.View.Table exposing + ( view + ) + +import Date exposing (Date) +import Dict exposing (..) +import String exposing (append) + +import FontAwesome +import View.Color as Color + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) + +import Dialog +import Dialog.AddPayment.Model as AddPayment +import Dialog.AddPayment.View as AddPayment + +import Tooltip + +import Msg exposing (Msg) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Msg as LoggedInMsg + +import LoggedIn.Home.Model as Home +import LoggedIn.View.Format as Format +import View.Date as Date + +import Model.Payment as Payment exposing (..) +import Model.PaymentCategory as PaymentCategory +import Model.Translations exposing (getMessage) +import Model.User exposing (getUserName) + +view : LoggedData -> Home.Model -> Payments -> Frequency -> Html Msg +view loggedData homeModel payments frequency = + let visiblePayments = + payments + |> List.drop ((homeModel.currentPage - 1) * perPage) + |> List.take perPage + in div + [ class "table" ] + [ div + [ class "lines" ] + ( headerLine loggedData frequency :: List.map (paymentLine loggedData homeModel frequency) visiblePayments ) + , if List.isEmpty visiblePayments + then + div + [ class "emptyTableMsg" ] + [ text <| getMessage loggedData.translations "NoPayment" ] + else + text "" + ] + +headerLine : LoggedData -> Frequency -> Html Msg +headerLine loggedData frequency = + div + [ class "header" ] + [ div [ class "cell category" ] [ text <| getMessage loggedData.translations "Name" ] + , div [ class "cell cost" ] [ text <| getMessage loggedData.translations "Cost" ] + , div [ class "cell user" ] [ text <| getMessage loggedData.translations "Payer" ] + , div [ class "cell user" ] [ text <| getMessage loggedData.translations "PaymentCategory" ] + , case frequency of + Punctual -> div [ class "cell date" ] [ text <| getMessage loggedData.translations "Date" ] + Monthly -> text "" + , div [ class "cell" ] [] + , div [ class "cell" ] [] + , div [ class "cell" ] [] + ] + +paymentLine : LoggedData -> Home.Model -> Frequency -> Payment -> Html Msg +paymentLine loggedData homeModel frequency payment = + div + [ class "row" ] + [ div [ class "cell name" ] [ text payment.name ] + , div + [ classList + [ ("cell cost", True) + , ("refund", payment.cost < 0) + ] + ] + [ text (Format.price loggedData.conf payment.cost) ] + , div + [ class "cell user" ] + [ payment.userId + |> getUserName loggedData.users + |> Maybe.withDefault "−" + |> text + ] + , div + [ class "cell category" ] + ( let mbCategory = + PaymentCategory.search payment.name loggedData.paymentCategories + |> Maybe.andThen (\category -> Dict.get category loggedData.categories) + in case mbCategory of + Just category -> + [ span + [ class "tag" + , style [("background-color", category.color)] + ] + [ text category.name ] + ] + Nothing -> + [] + ) + , case frequency of + Punctual -> + div + [ class "cell date" ] + [ span + [ class "shortDate" ] + [ text (Date.shortView payment.date loggedData.translations) ] + , span + [ class "longDate" ] + [ text (Date.longView payment.date loggedData.translations) ] + ] + Monthly -> + text "" + , div + [ class "cell button" ] + [ let currentDate = Date.fromTime loggedData.currentTime + category = PaymentCategory.search payment.name loggedData.paymentCategories + in AddPayment.button + loggedData + (AddPayment.initialClone loggedData.translations currentDate category payment) + "ClonePayment" + (FontAwesome.clone Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Clone")) + ] + , div + [ class "cell button" ] + [ if loggedData.me /= payment.userId + then + text "" + else + let category = PaymentCategory.search payment.name loggedData.paymentCategories + in AddPayment.button + loggedData + (AddPayment.initialEdit loggedData.translations category payment) + "EditPayment" + (FontAwesome.pencil Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Edit")) + ] + , div + [ class "cell button" ] + [ if loggedData.me /= payment.userId + then + text "" + else + let dialogConfig = + { className = "deletePaymentDialog" + , title = getMessage loggedData.translations "ConfirmPaymentDelete" + , body = always <| text "" + , confirm = getMessage loggedData.translations "Confirm" + , confirmMsg = always <| Msg.Dialog <| Dialog.UpdateAndClose <| Msg.DeletePayment payment.id + , undo = getMessage loggedData.translations "Undo" + } + in button + ( Tooltip.show Msg.Tooltip (getMessage loggedData.translations "Delete") + ++ [ onClick (Msg.Dialog <| Dialog.Open dialogConfig) ] + ) + [ FontAwesome.trash Color.chestnutRose 18 ] + ] + ] diff --git a/src/client/LoggedIn/Income/Model.elm b/src/client/LoggedIn/Income/Model.elm new file mode 100644 index 0000000..7d852b9 --- /dev/null +++ b/src/client/LoggedIn/Income/Model.elm @@ -0,0 +1,36 @@ +module LoggedIn.Income.Model exposing + ( Model + , AddIncome + , init + , initForm + , validation + ) + +import Date exposing (Date) + +import Form exposing (Form) +import Form.Validate as Validate exposing (Validation) +import Validation + +type alias Model = + { addIncome : Form String AddIncome + } + +type alias AddIncome = + { amount : Int + , date : Date + } + +init : Model +init = + { addIncome = initForm + } + +initForm : Form String AddIncome +initForm = Form.initial [] validation + +validation : Validation String AddIncome +validation = + Validate.map2 AddIncome + (Validate.field "amount" (Validate.int |> Validate.andThen (Validate.minInt 1))) + (Validate.field "date" Validation.date) diff --git a/src/client/LoggedIn/Income/Msg.elm b/src/client/LoggedIn/Income/Msg.elm new file mode 100644 index 0000000..0a09dad --- /dev/null +++ b/src/client/LoggedIn/Income/Msg.elm @@ -0,0 +1,9 @@ +module LoggedIn.Income.Msg exposing + ( Msg(..) + ) + +import Form exposing (Form) + +type Msg = + NoOp + | AddIncomeMsg Form.Msg diff --git a/src/client/LoggedIn/Income/Update.elm b/src/client/LoggedIn/Income/Update.elm new file mode 100644 index 0000000..0023c76 --- /dev/null +++ b/src/client/LoggedIn/Income/Update.elm @@ -0,0 +1,24 @@ +module LoggedIn.Income.Update exposing + ( update + ) + +import Form exposing (Form) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Income.Model as Income +import LoggedIn.Income.Msg as Income + +update : LoggedData -> Income.Msg -> Income.Model -> (Income.Model, Cmd Income.Msg) +update loggedData msg model = + case msg of + + Income.NoOp -> + ( model + , Cmd.none + ) + + Income.AddIncomeMsg formMsg -> + ( { model | addIncome = Form.update Income.validation formMsg model.addIncome } + , Cmd.none + ) diff --git a/src/client/LoggedIn/Income/View.elm b/src/client/LoggedIn/Income/View.elm new file mode 100644 index 0000000..00a1646 --- /dev/null +++ b/src/client/LoggedIn/Income/View.elm @@ -0,0 +1,108 @@ +module LoggedIn.Income.View exposing + ( view + ) + +import Dict +import Date +import Time exposing (Time) +import Task + +import FontAwesome + +import Html exposing (..) +import Html.Events exposing (..) +import Html.Attributes exposing (..) + +import Form exposing (Form) +import View.Form as Form +import View.Events exposing (onSubmitPrevDefault) + +import Dialog +import Dialog.AddIncome.Model as AddIncome +import Dialog.AddIncome.View as AddIncome + +import Msg exposing (Msg) + +import LoggedData exposing (LoggedData) + +import Model.Income exposing (IncomeId, Income, userCumulativeIncomeSince) +import Model.Translations exposing (getMessage, getParamMessage) +import Model.Payer exposing (useIncomesFrom) +import Model.User exposing (UserId, User) +import Model.View as View +import LoggedIn.Income.Model as Income + +import LoggedIn.Msg as LoggedInMsg +import LoggedIn.Income.Msg as IncomeMsg + +import View.Date as Date +import LoggedIn.View.Format as Format +import View.Color as Color +import LoggedIn.Income.View.Table as Table + +view : LoggedData -> Income.Model -> Html Msg +view loggedData incomeModel = + div + [ class "income" ] + [ div + [ class "withMargin" ] + [ case useIncomesFrom loggedData.users loggedData.incomes loggedData.payments of + Just since -> cumulativeIncomesView loggedData since + Nothing -> text "" + , div + [ class "titleButton" ] + [ h1 [] [ text <| getMessage loggedData.translations "MonthlyNetIncomes" ] + , AddIncome.button + loggedData + (AddIncome.initialAdd loggedData.translations (Date.fromTime loggedData.currentTime)) + "AddIncome" + (text (getMessage loggedData.translations "AddIncome")) + Nothing + ] + ] + , Table.view loggedData incomeModel + ] + +cumulativeIncomesView : LoggedData -> Time -> Html Msg +cumulativeIncomesView loggedData since = + let longDate = Date.longView (Date.fromTime since) loggedData.translations + in div + [] + [ h1 [] [ text <| getParamMessage [longDate] loggedData.translations "CumulativeIncomesSince" ] + , ul + [] + ( Dict.toList loggedData.users + |> List.map (\(userId, user) -> + (user.name, userCumulativeIncomeSince loggedData.currentTime since loggedData.incomes userId) + ) + |> List.sortBy Tuple.second + |> List.map (\(userName, cumulativeIncome) -> + li + [] + [ text userName + , text " − " + , text <| Format.price loggedData.conf cumulativeIncome + ] + ) + ) + ] + +incomeView : LoggedData -> (IncomeId, Income) -> Html Msg +incomeView loggedData (incomeId, income) = + li + [] + [ text <| Date.shortView (Date.fromTime income.time) loggedData.translations + , text " − " + , text <| Format.price loggedData.conf income.amount + , let dialogConfig = + { className = "deleteIncomeDialog" + , title = getMessage loggedData.translations "ConfirmIncomeDelete" + , body = always <| text "" + , confirm = getMessage loggedData.translations "Confirm" + , confirmMsg = always <| Msg.Dialog <| Dialog.UpdateAndClose <| Msg.DeleteIncome incomeId + , undo = getMessage loggedData.translations "Undo" + } + in button + [ onClick (Msg.Dialog <| Dialog.Open dialogConfig) ] + [ FontAwesome.trash Color.chestnutRose 14 ] + ] diff --git a/src/client/LoggedIn/Income/View/Table.elm b/src/client/LoggedIn/Income/View/Table.elm new file mode 100644 index 0000000..aa5e392 --- /dev/null +++ b/src/client/LoggedIn/Income/View/Table.elm @@ -0,0 +1,129 @@ +module LoggedIn.Income.View.Table exposing + ( view + ) + +import Dict exposing (..) +import Date exposing (Date) +import String exposing (append) + +import FontAwesome +import View.Color as Color + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) + +import Dialog +import Dialog.AddIncome.Model as AddIncome +import Dialog.AddIncome.View as AddIncome + +import Tooltip + +import Msg exposing (Msg) + +import LoggedData exposing (LoggedData) + +import LoggedIn.Msg as LoggedInMsg + +import LoggedIn.Income.Model as Income +import View.Date as Date +import LoggedIn.View.Format as Format + +import Model.User exposing (getUserName) +import Model.Income as Income exposing (..) +import Model.Translations exposing (getMessage) + +view : LoggedData -> Income.Model -> Html Msg +view loggedData incomeModel = + let incomes = + loggedData.incomes + |> Dict.toList + |> List.sortBy (.time << Tuple.second) + |> List.reverse + in div + [ class "table" ] + [ div + [ class "lines" ] + ( headerLine loggedData :: List.map (paymentLine loggedData incomeModel) incomes) + , if List.isEmpty (Dict.toList loggedData.incomes) + then + div + [ class "emptyTableMsg" ] + [ text <| getMessage loggedData.translations "NoIncome" ] + else + text "" + ] + +headerLine : LoggedData -> Html Msg +headerLine loggedData = + div + [ class "header" ] + [ div [ class "cell name" ] [ text <| getMessage loggedData.translations "Name" ] + , div [ class "cell income" ] [ text <| getMessage loggedData.translations "Income" ] + , div [ class "cell date" ] [ text <| getMessage loggedData.translations "Date" ] + , div [ class "cell" ] [] + , div [ class "cell" ] [] + , div [ class "cell" ] [] + ] + +paymentLine : LoggedData -> Income.Model -> (IncomeId, Income) -> Html Msg +paymentLine loggedData incomeModel (incomeId, income) = + div + [ class "row" ] + [ div + [ class "cell name" ] + [ income.userId + |> getUserName loggedData.users + |> Maybe.withDefault "−" + |> text + ] + , div + [ class "cell income" ] + [ text (Format.price loggedData.conf income.amount) ] + , div + [ class "cell date" ] + [ text (Date.longView (Date.fromTime income.time) loggedData.translations) ] + , div + [ class "cell button" ] + [ let currentDate = Date.fromTime loggedData.currentTime + in AddIncome.button + loggedData + (AddIncome.initialClone loggedData.translations currentDate income) + "CloneIncome" + (FontAwesome.clone Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Clone")) + ] + , div + [ class "cell button" ] + [ if loggedData.me /= income.userId + then + text "" + else + AddIncome.button + loggedData + (AddIncome.initialEdit loggedData.translations incomeId income) + "EditIncome" + (FontAwesome.pencil Color.chestnutRose 18) + (Just (getMessage loggedData.translations "Edit")) + ] + , div + [ class "cell button" ] + [ if loggedData.me /= income.userId + then + text "" + else + let dialogConfig = + { className = "deleteIncomeDialog" + , title = getMessage loggedData.translations "ConfirmIncomeDelete" + , body = always <| text "" + , confirm = getMessage loggedData.translations "Confirm" + , confirmMsg = always <| Msg.Dialog <| Dialog.UpdateAndClose <| Msg.DeleteIncome incomeId + , undo = getMessage loggedData.translations "Undo" + } + in button + ( Tooltip.show Msg.Tooltip (getMessage loggedData.translations "Delete") + ++ [ onClick (Msg.Dialog <| Dialog.Open dialogConfig) ] + ) + [ FontAwesome.trash Color.chestnutRose 18 ] + ] + ] diff --git a/src/client/LoggedIn/Model.elm b/src/client/LoggedIn/Model.elm new file mode 100644 index 0000000..6bcb0b2 --- /dev/null +++ b/src/client/LoggedIn/Model.elm @@ -0,0 +1,42 @@ +module LoggedIn.Model exposing + ( Model + , init + ) + +import Time exposing (Time) + +import Model.Init exposing (..) +import Model.Payment exposing (Payments) +import Model.User exposing (Users, UserId) +import Model.Income exposing (Incomes) +import Model.Category exposing (Categories) +import Model.PaymentCategory exposing (PaymentCategories) + +import LoggedIn.Home.Model as Home +import LoggedIn.Income.Model as Income +import LoggedIn.Category.Model as Categories + +type alias Model = + { home : Home.Model + , income : Income.Model + , category : Categories.Model + , users : Users + , me : UserId + , payments : Payments + , incomes : Incomes + , categories : Categories + , paymentCategories : PaymentCategories + } + +init : Init -> Model +init initData = + { home = Home.init + , income = Income.init + , category = Categories.init + , users = initData.users + , me = initData.me + , payments = initData.payments + , incomes = initData.incomes + , categories = initData.categories + , paymentCategories = initData.paymentCategories + } diff --git a/src/client/LoggedIn/Msg.elm b/src/client/LoggedIn/Msg.elm new file mode 100644 index 0000000..a1379a6 --- /dev/null +++ b/src/client/LoggedIn/Msg.elm @@ -0,0 +1,28 @@ +module LoggedIn.Msg exposing + ( Msg(..) + ) + +import Date exposing (Date) + +import Model.Payment exposing (PaymentId, Frequency) +import Model.Income exposing (IncomeId) +import Model.Category exposing (CategoryId) + +import LoggedIn.Home.Msg as Home +import LoggedIn.Income.Msg as Income +import LoggedIn.Category.Msg as Categories + +type Msg = + NoOp + | HomeMsg Home.Msg + | IncomeMsg Income.Msg + | CategoriesMsg Categories.Msg + | ValidateCreatePayment PaymentId String Int Date CategoryId Frequency + | ValidateEditPayment PaymentId String Int Date CategoryId Frequency + | ValidateDeletePayment PaymentId + | ValidateCreateIncome IncomeId Int Date + | ValidateEditIncome IncomeId Int Date + | ValidateDeleteIncome IncomeId + | ValidateCreateCategory CategoryId String String + | ValidateEditCategory CategoryId String String + | ValidateDeleteCategory CategoryId diff --git a/src/client/LoggedIn/Stat/View.elm b/src/client/LoggedIn/Stat/View.elm new file mode 100644 index 0000000..f57316a --- /dev/null +++ b/src/client/LoggedIn/Stat/View.elm @@ -0,0 +1,62 @@ +module LoggedIn.Stat.View exposing + ( view + ) + +import Date exposing (Month) + +import Html exposing (..) +import Html.Attributes exposing (..) + +import LoggedData exposing (LoggedData) + +import Msg exposing (Msg) + +import Model.Payment as Payment exposing (Payments) +import Model.Conf exposing (Conf) +import Model.Translations exposing (getMessage, getParamMessage) + +import LoggedIn.View.Format as Format +import View.Date as Date +import View.Plural exposing (plural) + +import Utils.List as List + +view : LoggedData -> Html Msg +view loggedData = + let paymentsByMonth = Payment.groupAndSortByMonth (Payment.punctual loggedData.payments) + monthPaymentMean = getMonthPaymentMean loggedData paymentsByMonth + in div + [ class "stat withMargin" ] + [ h1 [] [ text (getParamMessage [ Format.price loggedData.conf monthPaymentMean ] loggedData.translations "ByMonthsAndMean") ] + , ul + [] + ( List.map (monthDetail loggedData) paymentsByMonth) + ] + +getMonthPaymentMean : LoggedData -> List ((Month, Int), Payments) -> Int +getMonthPaymentMean loggedData paymentsByMonth = + paymentsByMonth + |> List.filter (\((month, year), _) -> + let currentDate = Date.fromTime loggedData.currentTime + in not (Date.month currentDate == month && Date.year currentDate == year) + ) + |> List.map (List.sum << List.map .cost << Tuple.second) + |> List.mean + +monthDetail : LoggedData -> ((Month, Int), Payments) -> Html Msg +monthDetail loggedData ((month, year), payments) = + li + [] + [ text (Date.monthView loggedData.translations month) + , text " " + , text (toString year) + , text " − " + , text (paymentsSum loggedData.conf payments) + ] + +paymentsSum : Conf -> Payments -> String +paymentsSum conf payments = + payments + |> List.map .cost + |> List.sum + |> Format.price conf diff --git a/src/client/LoggedIn/Update.elm b/src/client/LoggedIn/Update.elm new file mode 100644 index 0000000..9e6d6ee --- /dev/null +++ b/src/client/LoggedIn/Update.elm @@ -0,0 +1,151 @@ +module LoggedIn.Update exposing + ( update + ) + +import Dict +import String +import Task + +import Http exposing (Error(..)) +import Date exposing (Date) +import Platform.Cmd exposing (Cmd) + +import Form + +import Model exposing (Model) +import Model.Payment as Payment exposing (Payment, Frequency(..)) +import Model.Income as Income exposing (Income) +import Model.Category exposing (Category) +import Model.PaymentCategory as PaymentCategory + +import Server +import LoggedData + +import LoggedIn.Msg as LoggedInMsg +import LoggedIn.Model as LoggedInModel + +import LoggedIn.Home.Msg as Home +import LoggedIn.Home.Update as Home +import LoggedIn.Home.Model as Home + +import LoggedIn.Income.Update as Income +import LoggedIn.Income.Model as Income + +import LoggedIn.Category.Update as Categories +import LoggedIn.Category.Model as Categories + +import Utils.Cmd exposing ((:>)) + +update : Model -> LoggedInMsg.Msg -> LoggedInModel.Model -> (LoggedInModel.Model, Cmd LoggedInMsg.Msg) +update model msg loggedIn = + let loggedData = LoggedData.build model loggedIn + in case msg of + + LoggedInMsg.NoOp -> + ( loggedIn + , Cmd.none + ) + + LoggedInMsg.HomeMsg homeMsg -> + case Home.update loggedData homeMsg loggedIn.home of + (home, effects) -> + ( { loggedIn | home = home } + , Cmd.map LoggedInMsg.HomeMsg effects + ) + + LoggedInMsg.IncomeMsg incomeMsg -> + case Income.update loggedData incomeMsg loggedIn.income of + (income, cmd) -> + ( { loggedIn | income = income } + , Cmd.map LoggedInMsg.IncomeMsg cmd + ) + + LoggedInMsg.CategoriesMsg categoriesMsg -> + case Categories.update loggedData categoriesMsg loggedIn.category of + (category, cmd) -> + ( { loggedIn | category = category } + , Cmd.map LoggedInMsg.CategoriesMsg cmd + ) + + LoggedInMsg.ValidateCreatePayment paymentId name cost date category frequency -> + update model (LoggedInMsg.HomeMsg <| Home.SearchMsg (Form.Reset (Home.searchInitial frequency))) loggedIn + :> update model (LoggedInMsg.HomeMsg <| Home.UpdatePage 1) + :> (\loggedIn -> + let newPayment = Payment paymentId name cost date loggedIn.me frequency + in ( { loggedIn + | payments = newPayment :: loggedIn.payments + , paymentCategories = PaymentCategory.set name category loggedIn.paymentCategories + } + , Cmd.none + ) + ) + + LoggedInMsg.ValidateEditPayment paymentId name cost date category frequency -> + let updatedPayment = Payment paymentId name cost date loggedIn.me frequency + mbOldPayment = Payment.find paymentId loggedIn.payments + in ( { loggedIn + | payments = Payment.edit updatedPayment loggedIn.payments + , paymentCategories = + case mbOldPayment of + Just oldPayment -> + PaymentCategory.update oldPayment.name name category loggedIn.paymentCategories + Nothing -> + loggedData.paymentCategories + } + , Cmd.none + ) + + LoggedInMsg.ValidateDeletePayment paymentId -> + let payments = Payment.delete paymentId loggedIn.payments + frequency = + case Form.getOutput loggedIn.home.search of + Just data -> data.frequency + Nothing -> Punctual + switchToPunctual = + ( frequency == Monthly + && List.isEmpty (Payment.monthly payments) + ) + in if switchToPunctual + then + update model (LoggedInMsg.HomeMsg <| Home.SearchMsg (Form.Reset (Home.searchInitial Punctual))) loggedIn + :> (\loggedIn -> + ( { loggedIn | payments = payments } + , Cmd.none + ) + ) + else + ( { loggedIn | payments = payments } + , Cmd.none + ) + + LoggedInMsg.ValidateCreateIncome incomeId amount date -> + let newIncome = { userId = loggedIn.me, amount = amount, time = Date.toTime date } + in ( { loggedIn | incomes = Dict.insert incomeId newIncome loggedIn.incomes } + , Cmd.none + ) + + LoggedInMsg.ValidateEditIncome incomeId amount date -> + let updateIncome _ = Just <| Income loggedIn.me (Date.toTime date) amount + in ( { loggedIn | incomes = Dict.update incomeId updateIncome loggedIn.incomes } + , Cmd.none + ) + + LoggedInMsg.ValidateDeleteIncome incomeId -> + ( { loggedIn | incomes = Dict.remove incomeId loggedIn.incomes } + , Cmd.none + ) + + LoggedInMsg.ValidateCreateCategory categoryId name color -> + let newCategory = { name = name, color = color } + in ( { loggedIn | categories = Dict.insert categoryId newCategory loggedIn.categories } + , Cmd.none + ) + + LoggedInMsg.ValidateEditCategory categoryId name color -> + let updateCategory _ = Just <| Category name color + in ( { loggedIn | categories = Dict.update categoryId updateCategory loggedIn.categories } , Cmd.none) + + LoggedInMsg.ValidateDeleteCategory categoryId -> + ( { loggedIn | categories = Dict.remove categoryId loggedIn.categories } + , Cmd.none + ) diff --git a/src/client/LoggedIn/View.elm b/src/client/LoggedIn/View.elm new file mode 100644 index 0000000..2e42a73 --- /dev/null +++ b/src/client/LoggedIn/View.elm @@ -0,0 +1,33 @@ +module LoggedIn.View exposing + ( view + ) + +import Html exposing (..) +import Html.Attributes exposing (..) + +import Page + +import Msg exposing (Msg) +import Model exposing (Model) +import Model.Translations exposing (getMessage) +import LoggedData + +import LoggedIn.Model as LoggedInModel + +import LoggedIn.Home.View as Home +import LoggedIn.Income.View as Income +import LoggedIn.Category.View as Categories +import LoggedIn.Stat.View as Stat + +view : Model -> LoggedInModel.Model -> Html Msg +view model loggedIn = + div + [ class "loggedIn" ] + [ let loggedData = LoggedData.build model loggedIn + in case model.page of + Page.Home -> Home.view loggedData loggedIn.home + Page.Income -> Income.view loggedData loggedIn.income + Page.Categories -> Categories.view loggedData loggedIn.category + Page.Statistics -> Stat.view loggedData + Page.NotFound -> div [] [ text (getMessage model.translations "PageNotFound") ] + ] diff --git a/src/client/LoggedIn/View/Format.elm b/src/client/LoggedIn/View/Format.elm new file mode 100644 index 0000000..f41e2cd --- /dev/null +++ b/src/client/LoggedIn/View/Format.elm @@ -0,0 +1,37 @@ +module LoggedIn.View.Format exposing + ( price + ) + +import String exposing (..) + +import Model.Conf exposing (Conf) + +price : Conf -> Int -> String +price conf amount = + ( number amount + ++ " " + ++ conf.currency + ) + +number : Int -> String +number n = + abs n + |> toString + |> toList + |> List.reverse + |> group 3 + |> List.intersperse [' '] + |> List.concat + |> List.reverse + |> fromList + |> append (if n < 0 then "-" else "") + +group : Int -> List a -> List (List a) +group n xs = + if List.length xs <= n + then + [xs] + else + let take = List.take n xs + drop = List.drop n xs + in take :: (group n drop) |