jon/frontend/Entry.elm

406 lines
13 KiB
Elm

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