module Entry exposing (main) import Browser import Http import Json.Decode as D import Json.Decode.Pipeline as P import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Calculator import NumberInput import Select {- Elm forces us to use the Elm architecture: ┌──────┐ ┌─────Model──► view ├──Html───────┐ │ └──────┘ │ │ │ ┌┴─────────────────────────────────▼┐ │ Elm runtime │ └▲─────────────────────────────────┬┘ │ │ │ ┌──────┐ │ └─Model+Cmd──┤update◄──Msg+Model──┘ └──────┘ This architecture is similar to what React does but its implementation in Elm is a bit special since it's purely functional and side effects are isolated into the runtime system. An Elm component is usually centered around two types, Model and Msg. Model contains all data the application is concerned with, including the state of UI elements. Msg encodes all updates to Model that the application supports. In addition to Msg and Model, we have to provide two functions, view and update. view : Model -> Html Msg update : Msg -> Model -> (Model, Cmd Msg) view maps a Model to a DOM tree. Events in this DOM tree create Msg values. update maps a Msg and a Model to a new Model. In addition, update can create a command. Commands are used to make the runtime do side effects, which in turn create new Msg values. For example, we have a SetSearchTerm message which simply updates the searchTerm property in the model. This message is triggered every time the search box input is changed. Submitting the search box form triggers a SubmitSearch event. This event leaves the model unchanged but issues a command that sends the search term to a JSON endpoint. When the request successfully resolves, the runtime triggers a ReceiveSearchResults messages which updates the list of search results in the model. See Calculator.elm for a simpler example of this architecture. -} main = Browser.element { init = \globals -> ( Context globals <| ItemSearch { searchTerm = "", searchResults = [] } , Cmd.none ) , subscriptions = \_ -> Sub.none , update = update , view = view } -- Data types type alias SearchResult = { barcode : String , name : String , netUnitPrice : Float , bought : String , salesUnits : Int , available : Bool , locationName : String , locationId : Int , groupName : String , groupId : Int , taxGroupId : Int , unitsLeft : Int } searchResultDecoder = D.succeed SearchResult |> P.required "item_barcode" D.string |> P.required "name" D.string |> P.required "unit_price" D.float |> P.required "bought" D.string |> P.required "sales_units" D.int |> P.required "available" D.bool |> P.required "location_name" D.string |> P.required "location_id" D.int |> P.required "group_name" D.string |> P.required "group_id" D.int |> P.required "tax_group_id" D.int |> P.required "units_left" D.int type alias Location = { id : Int , name : String } type alias Group = { id : Int , name : String } type alias TaxGroup = { id : Int , description : String , percentage : Float } type alias NewItem = { barcode : String , name : String , salesUnits : Int , group : Group , location : Location , netUnitPrice : Float , taxGroup : TaxGroup } type alias Context = { globals : Globals , state : State } type alias Globals = { locations : List Location , groups : List Group , taxGroups : List TaxGroup } type State = ItemSearch { searchTerm : String , searchResults : List SearchResult } | ItemEditor { barcode : String , name : String , salesUnits : NumberInput.Model Int , calculator : Calculator.Model , netUnitPrice : NumberInput.Model Float , grossUnitPrice : NumberInput.Model Float , group : Select.Model Group , location : Select.Model Location , taxGroup : Select.Model TaxGroup } type Msg = SetSearchTerm String | SubmitSearch | ReceiveSearchResults (Result Http.Error (List SearchResult)) | GotoItemEditor SearchResult | SetBarcode String | SetName String | SetSalesUnits String | CalculatorMsg Calculator.Msg | SetNetUnitPrice String | SetGrossUnitPrice String | SetGroup String | SetLocation String | SetTaxGroup String -- Update logic: State machine etc. update msg { globals, state } = let (state_, cmd) = updateState msg globals state in ({ globals = globals, state = state_ }, cmd) updateState msg globals state = case state of ItemSearch model -> case msg of SetSearchTerm searchTerm -> (ItemSearch { model | searchTerm = searchTerm }, Cmd.none) SubmitSearch -> ( state , Http.get { url = "/entry/api/search-items?search-term=" ++ model.searchTerm , expect = Http.expectJson ReceiveSearchResults <| D.list searchResultDecoder } ) ReceiveSearchResults (Ok searchResults) -> (ItemSearch { model | searchResults = searchResults }, Cmd.none) GotoItemEditor searchResult -> case find (\tg -> tg.id == searchResult.taxGroupId) globals.taxGroups of Nothing -> (state, Cmd.none) Just taxGroup -> ( ItemEditor { barcode = searchResult.barcode , name = searchResult.name , calculator = Calculator.init searchResult.netUnitPrice , netUnitPrice = NumberInput.fromFloat searchResult.netUnitPrice , grossUnitPrice = NumberInput.fromFloat (suggestedGrossPrice searchResult.netUnitPrice taxGroup.percentage) , salesUnits = NumberInput.fromInt searchResult.salesUnits , group = Select.init (.id >> String.fromInt) (.name) { id = searchResult.groupId, name = searchResult.groupName } globals.groups , location = Select.init (.id >> String.fromInt) (.name) { id = searchResult.locationId, name = searchResult.locationName } globals.locations , taxGroup = Select.init (.id >> String.fromInt) (.description) taxGroup globals.taxGroups } , Cmd.none ) _ -> (state, Cmd.none) ItemEditor model -> case msg of SetBarcode barcode -> (ItemEditor { model | barcode = barcode }, Cmd.none) SetName name -> (ItemEditor { model | name = name }, Cmd.none) SetSalesUnits str -> (ItemEditor { model | salesUnits = NumberInput.update str model.salesUnits }, Cmd.none) CalculatorMsg msg_ -> (ItemEditor { model | calculator = Calculator.update msg_ model.calculator }, Cmd.none) SetNetUnitPrice str -> ( ItemEditor { model | netUnitPrice = NumberInput.update str model.netUnitPrice } , Cmd.none ) SetGrossUnitPrice str -> ( ItemEditor { model | grossUnitPrice = NumberInput.update str model.grossUnitPrice } , Cmd.none ) SetGroup key -> (ItemEditor { model | group = Select.update key model.group }, Cmd.none) SetLocation key -> (ItemEditor { model | location = Select.update key model.location }, Cmd.none) SetTaxGroup key -> ( ItemEditor { model | taxGroup = Select.update key model.taxGroup } , Cmd.none ) _ -> (state, Cmd.none) suggestedGrossPrice netPrice percentage = roundTo 2 <| netPrice * (1 + percentage) + 0.01 -- View stuff view { globals, state } = case state of ItemSearch model -> fieldset [] [ legend [] [ text "Vorlage für Auftrag wählen" ] , Html.form [ onSubmit SubmitSearch ] [ div [ class "form-input" ] [ label [ for "search-term", title "Barcode oder Name" ] [ text "Suchbegriff" ] , input [ onInput SetSearchTerm, value model.searchTerm, id "search-term" ] [] ] , table [] <| searchResultHeaders :: List.map viewSearchResult model.searchResults ] ] ItemEditor model -> Html.form [ method "POST" ] [ fieldset [] [ legend [] [ text "Neuer Inventareintrag" ] , div [ class "form-input" ] [ label [ for "barcode" ] [ text "Barcode" ] , input [ onInput SetBarcode, value model.barcode, name "barcode", id "barcode" ] [] ] , div [ class "form-input" ] [ label [ for "name" ] [ text "Name" ] , input [ onInput SetName, value model.name, name "name", id "name" ] [] ] , div [ class "form-input" ] [ label [ for "sales-units" ] [ text "Eingekauft" ] , input [ onInput SetSalesUnits, value <| NumberInput.show model.salesUnits, name "sales-units", id "sales-units", type_ "number" ] [] ] , div [ class "form-input" ] [ label [ for "group" ] [ text "Gruppe" ] , Select.view [ name "group-id", id "group" ] SetGroup model.group , input [ type_ "hidden" , name "group-name" , value <| (Select.get model.group).name ] [] ] , div [ class "form-input" ] [ label [ for "location" ] [ text "Raum" ] , Select.view [ name "location-id", id "location" ] SetLocation model.location , input [ type_ "hidden" , name "location-name" , value <| (Select.get model.location).name ] [] ] , div [ class "form-input" ] [ label [ for "tax-group" ] [ text "Steuergruppe" ] , Select.view [ name "tax-group-id", id "tax-group" ] SetTaxGroup model.taxGroup , input [ type_ "hidden" , name "tax-group-description" , value <| (Select.get model.taxGroup).description ] [] ] , Html.map CalculatorMsg <| Calculator.view model.calculator (Select.get model.taxGroup) , div [ class "form-input" ] [ label [ for "net-unit-price" ] [ text "Einkaufspreis (Netto)" ] , input [ value <| NumberInput.show model.netUnitPrice , onInput SetNetUnitPrice , type_ "number" , name "net-unit-price" , id "net-unit-price" , step "0.01" ] [] , viewSetCalculatedPriceButton model ] , div [ class "form-input" ] [ label [ for "gross-unit-price" ] [ text "Verkaufspreis (Brutto)" ] , input [ value <| NumberInput.show model.grossUnitPrice , onInput SetGrossUnitPrice , type_ "number" , name "gross-unit-price" , id "gross-unit-price" , step "0.01" ] [] , viewSetSuggestedPriceButton model ] , button [] [ text "Auftrag anlegen" ] ] ] viewSetCalculatedPriceButton model = case Calculator.getResult model.calculator (Select.get model.taxGroup) of Nothing -> button [ disabled True ] [ text "Auf ? setzen" ] Just calculatedPrice -> button [ onClick <| SetNetUnitPrice <| String.fromFloat calculatedPrice -- Prevent submitting the form , type_ "button" ] [ text <| "Auf " ++ String.fromFloat calculatedPrice ++ " setzen" ] viewSetSuggestedPriceButton model = case NumberInput.get model.netUnitPrice of Nothing -> button [ disabled True ] [ text "Auf ? setzen" ] Just netUnitPrice -> let grossUnitPrice = suggestedGrossPrice netUnitPrice (Select.get model.taxGroup).percentage in button [ onClick <| SetGrossUnitPrice <| String.fromFloat grossUnitPrice -- Prevent submitting the form , type_ "button" ] [ text <| "Auf " ++ String.fromFloat grossUnitPrice ++ " setzen" ] searchResultHeaders = tr [] [ th [] [ text "Barcode" ] , th [] [ text "Name" ] , th [] [ text "Gruppe" ] , th [] [ text "Stückpreis (Netto)" ] , th [] [ text "Eingekauft" ] , th [] [ text "Kaufdatum" ] , th [] [ text "Raum" ] , th [] [ text "Aktiv?" ] , th [] [] ] viewSearchResult model = tr [] [ td [] [ code [] [ text model.barcode ] ] , td [] [ text model.name ] , td [] [ text model.groupName ] , td [] [ text <| String.fromFloat model.netUnitPrice ] , td [] [ text <| String.fromInt model.salesUnits ] , td [] [ text model.bought ] , td [] [ text model.locationName ] , td [] [ text <| showBool model.available ] , td [] [ Html.form [ onSubmit <| GotoItemEditor model ] [ button [] [ text "Als Vorlage verwenden" ] ] ] ] calculateGarfieldPrice model = NumberInput.get model.netUnitPrice |> Maybe.map (\netUnitPrice -> roundTo 2 <| netUnitPrice * (1 + (Select.get model.taxGroup).percentage) + 0.01 ) roundTo places x = toFloat (round <| x * 10 ^ places) / 10 ^ places showBool b = case b of True -> "✅" False -> "❌" find pred xs = List.head <| List.filter pred xs