First draft of elm frontend
This will probably be scrapped or rewritten
This commit is contained in:
		
							parent
							
								
									e9998b9e8e
								
							
						
					
					
						commit
						afd7e9369d
					
				| @ -35,3 +35,4 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de | ||||
|   - [ ] etc. | ||||
|   - [ ] Make it print nicely | ||||
| - [ ] Make it possible to edit entries | ||||
| - [ ] Improve project structure | ||||
|  | ||||
							
								
								
									
										28
									
								
								elm.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								elm.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| { | ||||
|     "type": "application", | ||||
|     "source-directories": [ | ||||
|         "frontend" | ||||
|     ], | ||||
|     "elm-version": "0.19.1", | ||||
|     "dependencies": { | ||||
|         "direct": { | ||||
|             "NoRedInk/elm-json-decode-pipeline": "1.0.1", | ||||
|             "elm/browser": "1.0.2", | ||||
|             "elm/core": "1.0.5", | ||||
|             "elm/html": "1.0.0", | ||||
|             "elm/http": "2.0.0", | ||||
|             "elm/json": "1.1.3" | ||||
|         }, | ||||
|         "indirect": { | ||||
|             "elm/bytes": "1.0.8", | ||||
|             "elm/file": "1.0.5", | ||||
|             "elm/time": "1.0.0", | ||||
|             "elm/url": "1.0.0", | ||||
|             "elm/virtual-dom": "1.0.3" | ||||
|         } | ||||
|     }, | ||||
|     "test-dependencies": { | ||||
|         "direct": {}, | ||||
|         "indirect": {} | ||||
|     } | ||||
| } | ||||
							
								
								
									
										292
									
								
								frontend/Entry.elm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								frontend/Entry.elm
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,292 @@ | ||||
| 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 (..) | ||||
| 
 | ||||
| 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 | ||||
|   } | ||||
| 
 | ||||
| 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 | ||||
| 
 | ||||
| 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 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 : Int | ||||
|     , netUnitPrice : Float | ||||
|     , groupId : Int | ||||
|     , locationId : Int | ||||
|     , taxGroupId : Int | ||||
|     } | ||||
| 
 | ||||
| type Msg | ||||
|   = SetSearchTerm String | ||||
|   | SubmitSearch | ||||
|   | ReceiveSearchResults (Result Http.Error (List SearchResult)) | ||||
|   | GotoItemEditor SearchResult | ||||
|   | SetNetUnitPrice String | ||||
|   | SetGroupId String | ||||
|   | SetLocationId String | ||||
|   | SetTaxGroupId String | ||||
|   | SetBarcode String | ||||
|   | SetName String | ||||
|   | SetSalesUnits 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 -> | ||||
|       ( ItemEditor | ||||
|         { barcode = searchResult.barcode | ||||
|         , groupId = searchResult.groupId | ||||
|         , locationId = searchResult.locationId | ||||
|         , name = searchResult.name | ||||
|         , netUnitPrice = searchResult.netUnitPrice | ||||
|         , salesUnits = searchResult.salesUnits | ||||
|         , taxGroupId = searchResult.taxGroupId | ||||
|         } | ||||
|       , Cmd.none | ||||
|       ) | ||||
|     _ -> | ||||
|       (state, Cmd.none) | ||||
|   ItemEditor model -> case msg of | ||||
|     SetNetUnitPrice netUnitPriceStr -> case String.toFloat netUnitPriceStr of | ||||
|       Nothing -> (state, Cmd.none) | ||||
|       Just netUnitPrice -> (ItemEditor { model | netUnitPrice = netUnitPrice }, Cmd.none) | ||||
|     SetGroupId groupIdStr -> case String.toInt groupIdStr of | ||||
|       Nothing -> (state, Cmd.none) | ||||
|       Just groupId -> (ItemEditor { model | groupId = groupId }, Cmd.none) | ||||
|     SetLocationId locationIdStr -> case String.toInt locationIdStr of | ||||
|       Nothing -> (state, Cmd.none) | ||||
|       Just locationId -> (ItemEditor { model | locationId = locationId }, Cmd.none) | ||||
|     SetTaxGroupId taxGroupIdStr -> case String.toInt taxGroupIdStr of | ||||
|       Nothing -> (state, Cmd.none) | ||||
|       Just taxGroupId -> (ItemEditor { model | taxGroupId = taxGroupId }, Cmd.none) | ||||
|     SetBarcode barcode -> | ||||
|       (ItemEditor { model | barcode = barcode }, Cmd.none) | ||||
|     SetName name -> | ||||
|       (ItemEditor { model | name = name }, Cmd.none) | ||||
|     SetSalesUnits salesUnitsStr -> case String.toInt salesUnitsStr of | ||||
|       Nothing -> (state, Cmd.none) | ||||
|       Just salesUnits -> (ItemEditor { model | salesUnits = salesUnits }, Cmd.none) | ||||
|     _ -> | ||||
|       (state, Cmd.none) | ||||
| 
 | ||||
| -- View stuff | ||||
| 
 | ||||
| view { globals, state } = case state of | ||||
|   ItemSearch model -> | ||||
|     fieldset [] | ||||
|       [ legend [] [ text "Vorlage für neuen Inventareintrag" ] | ||||
|       , 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 -> case find (\tg -> tg.id == model.taxGroupId) globals.taxGroups of | ||||
|     Nothing -> div [] [ text "index error, this should never happen" ] | ||||
|     Just selectedTaxGroup -> | ||||
|       Html.form [] | ||||
|         [ fieldset [] | ||||
|           [ legend [] [ text "Neuer Inventareintrag" ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "barcode" ] [ text "Barcode" ] | ||||
|             , input [ onInput SetBarcode, value model.barcode, disabled True, id "barcode" ] [] | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "name" ] [ text "Name" ] | ||||
|             , input [ onInput SetName, value model.name, id "name" ] [] | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "sales-units" ] [ text "Stückzahl" ] | ||||
|             , input [ onInput SetSalesUnits, value <| String.fromInt model.salesUnits, id "sales-units", type_ "number" ] [] | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "group" ] [ text "Gruppe" ] | ||||
|             , viewSelect | ||||
|               [ onInput SetGroupId, id "group" ] | ||||
|               String.fromInt .id .name model.groupId globals.groups | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "location" ] [ text "Raum" ] | ||||
|             , viewSelect | ||||
|               [ onInput SetLocationId, id "location" ] | ||||
|               String.fromInt .id .name model.locationId globals.locations | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "net-unit-price" ] [ text "Stückpreis (Netto)" ] | ||||
|             , input | ||||
|               [ value <| String.fromFloat model.netUnitPrice | ||||
|               , onInput SetNetUnitPrice | ||||
|               , type_ "number" | ||||
|               , id "net-unit-price" | ||||
|               , step "0.01" | ||||
|               ] | ||||
|               [] | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "tax-group" ] [ text "Steuergruppe" ] | ||||
|             , viewSelect | ||||
|               [ onInput SetTaxGroupId, id "tax-group" ] | ||||
|               String.fromInt .id .description model.taxGroupId globals.taxGroups | ||||
|             ] | ||||
|           ] | ||||
|         , fieldset [] | ||||
|           [ legend [] [ text "Neuer Snackeintrag" ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "snack-name" ] [ text "Name" ] | ||||
|             , input [ value model.name, disabled True, id "snack-name" ] [] | ||||
|             ] | ||||
|           , div [ class "form-input" ] | ||||
|             [ label [ for "gross-unit-price" ] | ||||
|               [ text <| "Stückpreis (Brutto), Vorschlag: " ++ String.fromFloat (calculateGarfieldPrice model.netUnitPrice selectedTaxGroup.percentage) | ||||
|               ] | ||||
|             , input [ value <| String.fromFloat <| Maybe.withDefault (calculateGarfieldPrice model.netUnitPrice selectedTaxGroup.percentage) Nothing, id "gross-unit-price", type_ "number", step "0.01" ] [] | ||||
|             ] | ||||
|           ] | ||||
|         ] | ||||
| 
 | ||||
| viewSelect selectAttributes showValue getValue getLabel selectedValue xs = | ||||
|   let | ||||
|     viewOption x = | ||||
|       option | ||||
|         [ value <| showValue <| getValue x | ||||
|         , selected <| getValue x == selectedValue | ||||
|         ] | ||||
|         [ text <| getLabel x | ||||
|         ] | ||||
|   in | ||||
|     select selectAttributes <| List.map viewOption xs | ||||
| 
 | ||||
| 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 [] | ||||
|       [ button [ onClick <| GotoItemEditor model ] [ text "Als Vorlage verwenden" ] | ||||
|       ] | ||||
|     ] | ||||
| 
 | ||||
| calculateGarfieldPrice netUnitPrice taxPercentage = | ||||
|   roundTo 2 <| netUnitPrice * (1 + taxPercentage) + 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 | ||||
| @ -1,6 +1,6 @@ | ||||
| SELECT | ||||
|   group_id, | ||||
|   group_name | ||||
|   group_id AS id, | ||||
|   group_name AS name | ||||
| FROM garfield.inventory_item_groups | ||||
| ORDER BY | ||||
|   group_name ASC | ||||
							
								
								
									
										4
									
								
								jon/db/entry/get_locations.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								jon/db/entry/get_locations.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| SELECT | ||||
|   location_id AS id, | ||||
|   location_name AS name | ||||
| FROM garfield.locations | ||||
							
								
								
									
										9
									
								
								jon/db/entry/get_tax_groups.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								jon/db/entry/get_tax_groups.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| SELECT | ||||
|   tax_group_id AS id, | ||||
|   description, | ||||
|   tax_percentage :: float AS percentage, | ||||
|   tax_description AS description | ||||
| FROM garfield.tax_groups, | ||||
|      LATERAL garfield.tax_find(tax_group_id, NOW() :: date) AS tax_id | ||||
| LEFT JOIN garfield.taxes USING (tax_id) | ||||
| WHERE active | ||||
							
								
								
									
										18
									
								
								jon/db/search_items.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								jon/db/search_items.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | ||||
| SELECT | ||||
|   item_barcode, | ||||
|   name, | ||||
|   unit_price :: float, | ||||
|   TO_CHAR(bought, 'YYYY-MM-DD') AS bought, | ||||
|   sales_units, | ||||
|   available, | ||||
|   location_name, | ||||
|   location_id, | ||||
|   group_name, | ||||
|   item_group AS group_id, | ||||
|   tax_group AS tax_group_id | ||||
| FROM garfield.inventory_items | ||||
| LEFT JOIN garfield.locations ON location = location_id | ||||
| LEFT JOIN garfield.inventory_item_groups ON item_group = group_id | ||||
| WHERE (%(location_id)s IS NULL OR location = %(location_id)s) | ||||
|   AND (name ILIKE CONCAT('%%', %(search_term)s, '%%') OR item_barcode = %(search_term)s) | ||||
| ORDER BY bought DESC | ||||
							
								
								
									
										37
									
								
								jon/entry.py
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								jon/entry.py
									
									
									
									
									
								
							| @ -1,9 +1,6 @@ | ||||
| import datetime | ||||
| import zoneinfo | ||||
| 
 | ||||
| 
 | ||||
| from flask import Blueprint, redirect, render_template, request, session | ||||
| 
 | ||||
| 
 | ||||
| from . import db | ||||
| 
 | ||||
| 
 | ||||
| @ -12,4 +9,34 @@ bp = Blueprint("entry", __name__, url_prefix="/entry") | ||||
| 
 | ||||
| @bp.get("/") | ||||
| def index(): | ||||
|     return render_template("entry/index.html") | ||||
|     with db.run_query("entry/get_groups.sql") as cursor: | ||||
|         groups = cursor.fetchall() | ||||
| 
 | ||||
|     with db.run_query("entry/get_locations.sql") as cursor: | ||||
|         locations = cursor.fetchall() | ||||
| 
 | ||||
|     with db.run_query("entry/get_tax_groups.sql") as cursor: | ||||
|         tax_groups = cursor.fetchall() | ||||
| 
 | ||||
|     return render_template("entry/index.html", **{ | ||||
|         "locations": locations, | ||||
|         "groups": groups, | ||||
|         "tax_groups": tax_groups | ||||
|     }) | ||||
| 
 | ||||
| @bp.get("/api/search-items") | ||||
| def api_search_items(): | ||||
|     try: | ||||
|         search_term = request.args["search-term"] | ||||
|     except: | ||||
|         return {"error": "Missing query parameter `search-term`"}, 400 | ||||
| 
 | ||||
|     location = session.get("location", None) | ||||
| 
 | ||||
|     with db.run_query("search_items.sql", { | ||||
|         "location_id": None if location is None else location["location_id"], | ||||
|         "search_term": search_term | ||||
|     }) as cursor: | ||||
|         items = cursor.fetchall() | ||||
| 
 | ||||
|     return items | ||||
|  | ||||
							
								
								
									
										12011
									
								
								jon/static/entry.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12011
									
								
								jon/static/entry.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,4 +1,4 @@ | ||||
| import datetime | ||||
| import json | ||||
| 
 | ||||
| 
 | ||||
| def format_currency(x): | ||||
| @ -15,16 +15,5 @@ def format_bool(x): | ||||
|     return "✅" if x else "❌" | ||||
| 
 | ||||
| 
 | ||||
| def now(): | ||||
|     return datetime.datetime.now() | ||||
| 
 | ||||
| 
 | ||||
| def get_garfield_price(net_unit_price, tax_group_id): | ||||
|     if tax_group_id == 1: | ||||
|         tax_factor = 1.19 | ||||
|     elif tax_group_id == 2: | ||||
|         tax_factor = 1.07 | ||||
|     else: | ||||
|         raise Error("Unknown tax group ID") | ||||
| 
 | ||||
|     return net_unit_price * tax_factor + 0.01 | ||||
| def to_json(x): | ||||
|     return json.dumps(x) | ||||
|  | ||||
| @ -1,5 +1,16 @@ | ||||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% block content %} | ||||
| <a href="/entry/edit-item-data">Neuer Eintrag</a> | ||||
| <div class="entry-app"></div> | ||||
| <script src="{{ url_for('static', filename='entry.js') }}"></script> | ||||
| <script> | ||||
| Elm.Entry.init({ | ||||
|   node: document.querySelector('.entry-app'), | ||||
|   flags: { | ||||
|     locations: {{ to_json(locations) | safe }}, | ||||
|     groups: {{ to_json(groups) | safe }}, | ||||
|     taxGroups: {{ to_json(tax_groups) | safe }} | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
| {% endblock %} | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user