diff --git a/.gitignore b/.gitignore index 9a684f2..9051824 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ jon/config.json elm-stuff/ venv/ .venv/ +jon/static/*.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87b557b --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: frontend + +frontend: jon/static/entry.js + +jon/static/entry.js: $(shell find frontend -name '*.elm') + elm make --optimize frontend/Entry.elm --output $@ diff --git a/README.md b/README.md index 92511f2..448c599 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,19 @@ pip install -r requirements.txt You should probably use a virtualenv for that. I develop `jon` using Python 3.10 but it should work with older versions as well. +### Building Frontend JS + +Most of jon works without JS but there are some features that require it. +The frontend code lives in `./frontend` and is written using Elm, a functional language that compiles to JS. +To compile the Elm code to `.js` files, first make sure that the Elm compiler is installed: + +``` +elm --version +# 0.19.1 +``` + +Then run `make frontend`. + ## Running ``` @@ -27,8 +40,6 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de ## TODO -- [ ] Implement item and snack entry as Elm application - - [ ] Needs good documentation for maintainability - [ ] Implement and document report generation - [ ] How many days will the item last? - [ ] How many do we need to last X months? @@ -38,3 +49,9 @@ ssh -nNTvL 5432:fsmi-db.fsmi.org:5432 fsmi-login.fsmi.uni-karlsruhe.de - [ ] Fix unsafe client-side sessions, either: - [ ] Use `flask-session` for file-backed sessions - [ ] Use `flask-login` with a single user stored in memory +- [ ] Improve project structure +- [ ] Use `flask.flash` for error messages +- [x] Implement item and snack entry as Elm application + - [x] Figure out/Add documentation about building `entry.js` + - [ ] Clean up the code a little and add some comments + - [ ] Needs good documentation for maintainability diff --git a/elm.json b/elm.json new file mode 100644 index 0000000..48ea476 --- /dev/null +++ b/elm.json @@ -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": {} + } +} diff --git a/frontend/Calculator.elm b/frontend/Calculator.elm new file mode 100644 index 0000000..7d1e355 --- /dev/null +++ b/frontend/Calculator.elm @@ -0,0 +1,128 @@ +module Calculator exposing (..) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) + +import NumberInput +import Select + +type Tax = Net | Gross + +-- Duplicated from Entry.elm but too lazy to sandwich this out +type alias TaxGroup = + { id : Int + , description : String + , percentage : Float + } + +showTax : Tax -> String +showTax tax = case tax of + Gross -> "Brutto" + Net -> "Netto" + +type alias Model = + { tax : Select.Model Tax + , bundlePrice : NumberInput.Model Float + , bundleSize : NumberInput.Model Int + } + +init : Float -> Model +init bundlePrice = Model + (Select.init showTax showTax Net [Net, Gross]) + (NumberInput.fromFloat bundlePrice) + (NumberInput.fromInt 1) + +getResult : Model -> TaxGroup -> Maybe Float +getResult model taxGroup = + case (NumberInput.get model.bundlePrice, NumberInput.get model.bundleSize) of + (Just bundlePrice, Just bundleSize) -> + Just <| roundTo 2 <| + if Select.get model.tax == Gross then + (bundlePrice / toFloat bundleSize) / (1 + taxGroup.percentage) + else + bundlePrice / toFloat bundleSize + _ -> + Nothing + + +type Msg + = SetTax String + | SetBundlePrice String + | SetBundleSize String + +update : Msg -> Model -> Model +update msg model = case msg of + SetTax key -> + { model | tax = Select.update key model.tax } + SetBundlePrice str -> + { model | bundlePrice = NumberInput.update str model.bundlePrice } + SetBundleSize str -> + { model | bundleSize = NumberInput.update str model.bundleSize } + +view : Model -> TaxGroup -> Html Msg +view model taxGroup = + let + mainPart = + [ text "(" + , div [ class "form-input --inline" ] + [ label [ for "bundle-price" ] [ text "Gebindepreis" ] + , input + [ placeholder "Gebindepreis" + , value <| NumberInput.show model.bundlePrice + , onInput SetBundlePrice + , id "bundle-price" + , type_ "number" + , step "0.01" + ] + [] + ] + , text " ÷ " + , div [ class "form-input --inline" ] + [ label [ for "bundle-size" ] [ text "Gebindegröße" ] + , input + [ placeholder "Gebindegröße" + , value <| NumberInput.show model.bundleSize + , onInput SetBundleSize + , id "bundle-size" + , type_ "number" + , step "1" + ] + [] + ] + , text ") " + ] + taxPart = + [ text " ÷ " + , div [ class "form-input --inline" ] + [ label [ for "tax" ] [ text "Steuer" ] + , input + [ value <| String.fromFloat <| 1 + taxGroup.percentage + , id "tax" + , type_ "number" + , step "0.01" + , disabled True + ] + [] + ] + ] + resultPart = + [ text " = " + , text <| Maybe.withDefault "?" <| Maybe.map String.fromFloat <| getResult model taxGroup + ] + in + fieldset [] + [ legend [] [ text "Preisrechner" ] + , div [] <| List.concat <| List.filterMap identity + [ Just mainPart + , if Select.get model.tax == Gross then Just taxPart else Nothing + , Just resultPart + ] + , div [ class "form-input" ] + [ label [ for "calculator-tax" ] [ text "Gebindepreis ist" ] + , Select.view [] SetTax model.tax + ] + ] + +roundTo : Int -> Float -> Float +roundTo places x = toFloat (round <| x * 10 ^ toFloat places) / 10 ^ toFloat places diff --git a/frontend/Entry.elm b/frontend/Entry.elm new file mode 100644 index 0000000..55b7355 --- /dev/null +++ b/frontend/Entry.elm @@ -0,0 +1,405 @@ +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 diff --git a/frontend/NumberInput.elm b/frontend/NumberInput.elm new file mode 100644 index 0000000..f209dec --- /dev/null +++ b/frontend/NumberInput.elm @@ -0,0 +1,29 @@ +module NumberInput exposing (..) + +type alias Model a = + { value : Maybe a + , original : String + , convert : String -> Maybe a + } + +fromFloat : Float -> Model Float +fromFloat x = Model (Just x) (String.fromFloat x) String.toFloat + +fromInt : Int -> Model Int +fromInt x = Model (Just x) (String.fromInt x) String.toInt + +get : Model a -> Maybe a +get = .value + +withDefault : a -> Model a -> a +withDefault d = Maybe.withDefault d << get + +isValid : Model a -> Bool +isValid model = model.value /= Nothing + +update : String -> Model a -> Model a +update str model = + { model | value = model.convert str, original = str } + +show : Model a -> String +show = .original diff --git a/frontend/Select.elm b/frontend/Select.elm new file mode 100644 index 0000000..26bb473 --- /dev/null +++ b/frontend/Select.elm @@ -0,0 +1,36 @@ +module Select exposing (..) + +import Html exposing (Attribute, Html, option, select, text) +import Html.Attributes exposing (selected, value) +import Html.Events exposing (onInput) + +type alias Model a = + { identify : a -> String + , show : a -> String + , selected : a + , options : List a + } + +init : (a -> String) -> (a -> String) -> a -> List a -> Model a +init = Model + +update : String -> Model a -> Model a +update key model = + case find (\x -> key == model.identify x) model.options of + Nothing -> model + Just x -> { model | selected = x } + +view : List (Attribute m) -> (String -> m) -> Model a -> Html m +view attributes msg model = + let + viewOption x = + option + [ selected <| model.identify model.selected == model.identify x, value <| model.identify x ] + [ text <| model.show x ] + in + select ([ onInput msg ] ++ attributes) <| List.map viewOption model.options + +get : Model a -> a +get = .selected + +find pred xs = List.head <| List.filter pred xs diff --git a/jon/db/__init__.py b/jon/db/__init__.py index 4a3a945..19e1c79 100644 --- a/jon/db/__init__.py +++ b/jon/db/__init__.py @@ -7,8 +7,6 @@ from psycopg2.extras import RealDictCursor def get_db(): if "db" not in g: - # TODO: Make this configurable and use a default that works - # on the pool computers. g.db = psycopg2.connect(current_app.config["DB_CONNECTION_STRING"]) run_query_on(g.db, "add_views.sql", None) diff --git a/jon/db/add_views.sql b/jon/db/add_views.sql index 684fde9..a6c0834 100644 --- a/jon/db/add_views.sql +++ b/jon/db/add_views.sql @@ -11,6 +11,7 @@ SELECT inventory_items.available, inventory_items.item_group, inventory_items.location, + inventory_items.tax_group, inventory_item_groups.group_name, COALESCE(b.sales::numeric, 0::numeric) - COALESCE(cancel.count::numeric, 0::numeric) AS sales, inventory_items.sales_units::numeric - COALESCE(b.sales, 0::bigint)::numeric + COALESCE(c.delta, 0::numeric) + COALESCE(cancel.count::numeric, 0::numeric) AS units_left, diff --git a/jon/db/entry/add_item_and_snack_entry.sql b/jon/db/entry/add_item_and_snack_entry.sql new file mode 100644 index 0000000..59f8015 --- /dev/null +++ b/jon/db/entry/add_item_and_snack_entry.sql @@ -0,0 +1,26 @@ +DO $$ +DECLARE new_item_id integer; +DECLARE new_snack_id integer; +BEGIN + +-- Create a new inventory line +INSERT INTO garfield.inventory_items +(item_barcode, name, item_group, location, tax_group, sales_units, unit_price) +VALUES +(%(barcode)s, %(name)s, %(group_id)s, %(location_id)s, %(tax_group_id)s, %(sales_units)s, %(net_unit_price)s) +RETURNING item_id INTO new_item_id; + +-- Delete (i.e. mark as inactive) any old snacks with the same barcode in the given location +PERFORM garfield.snack_delete(snack_id) +FROM garfield.snacks +WHERE snack_barcode = %(barcode)s + AND location_id = %(location_id)s; + +-- Create a new snack entry... +SELECT garfield.snack_create(%(name)s, %(barcode)s, %(gross_unit_price)s, %(tax_group_id)s, %(location_id)s) +INTO new_snack_id; + +-- ... and map it to the new inventory line +PERFORM garfield.inventory_map_snack(new_snack_id, new_item_id); + +END$$ diff --git a/jon/db/get_groups.sql b/jon/db/entry/get_groups.sql similarity index 63% rename from jon/db/get_groups.sql rename to jon/db/entry/get_groups.sql index 33d5233..e83ff5c 100644 --- a/jon/db/get_groups.sql +++ b/jon/db/entry/get_groups.sql @@ -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 diff --git a/jon/db/entry/get_locations.sql b/jon/db/entry/get_locations.sql new file mode 100644 index 0000000..d199363 --- /dev/null +++ b/jon/db/entry/get_locations.sql @@ -0,0 +1,4 @@ +SELECT + location_id AS id, + location_name AS name +FROM garfield.locations diff --git a/jon/db/entry/get_tax_groups.sql b/jon/db/entry/get_tax_groups.sql new file mode 100644 index 0000000..a2156f9 --- /dev/null +++ b/jon/db/entry/get_tax_groups.sql @@ -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 diff --git a/jon/db/search_items.sql b/jon/db/search_items.sql new file mode 100644 index 0000000..58da8d5 --- /dev/null +++ b/jon/db/search_items.sql @@ -0,0 +1,17 @@ +SELECT + item_barcode, + name, + unit_price :: float, + TO_CHAR(bought, 'YYYY-MM-DD') AS bought, + sales_units, + available, + location_name, + location as location_id, + group_name, + item_group AS group_id, + tax_group AS tax_group_id, + units_left :: integer +FROM all_inventory_item_overview +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 diff --git a/jon/entry.py b/jon/entry.py index d97c423..7b6c780 100644 --- a/jon/entry.py +++ b/jon/entry.py @@ -1,59 +1,133 @@ -import datetime -import zoneinfo +from flask import Blueprint, flash, redirect, render_template, request, session -from flask import Blueprint, redirect, render_template, request, session - from . import db bp = Blueprint("entry", __name__, url_prefix="/entry") -@bp.get("/") +@bp.route("/", methods=["GET", "POST"]) def index(): - return render_template("entry/index.html") + cart = session.get("cart", default=[]) + return render_template( + "entry/index.html", + cart=cart + ) -@bp.route("/edit-item-data", methods=["GET", "POST"]) -def edit_item_data(): - if "entry" not in session: - session["entry"] = dict() +@bp.post("/add-new-items") +def add_new_entries(): + print(session) + i_know_what_im_doing = "i-know-what-im-doing" in request.form + if not i_know_what_im_doing: + return "Du weißt nicht was du tust", 400 + + orders = session.get("cart", default=[]) + if not orders: + return "Keine Aufträge", 404 + + # I'm aware of execute_many and extras.execute_values but we don't need to + # optimize here (yet?). This way it's a bit easier to use anyways. + for order in orders: + with db.run_query("entry/add_item_and_snack_entry.sql", order) as cursor: + pass + db.get_db().commit() + + # Reset the cart + session["cart"] = [] + + return redirect(request.referrer) + + +@bp.post("/delete-order") +def delete_order(): + try: + order_index = int(request.form["order-index"]) + except: + return "Incomplete or mistyped form", 400 + + cart = session.get("cart", default=[]) + del cart[order_index] + session["cart"] = cart + + return redirect(request.referrer) + + +@bp.route("/new-order", methods=["GET", "POST"]) +def new_order(): if request.method == "POST": - session["entry"] = { - "item_bought": datetime.datetime.strptime(request.form.get("item_bought"), "%Y-%m-%d"), - "item_barcode": request.form.get("item_barcode"), - "item_name": request.form.get("item_name"), - "item_group_id": int(request.form.get("item_group")), - "item_net_unit_price": float(request.form.get("item_net_unit_price")), - "item_tax_group_id": int(request.form.get("item_tax_group")), - "item_amount": int(request.form.get("item_amount")), - "item_location_id": int(request.form.get("item_location")) - } + try: + barcode = request.form["barcode"] + name = request.form["name"] + sales_units = int(request.form["sales-units"]) + group_id = int(request.form["group-id"]) + # group_name, location_name and tax_group_description are not + # necessarily needed here but storing them makes it easier to + # render the list of orders. + group_name = request.form["group-name"] + location_id = int(request.form["location-id"]) + location_name = request.form["location-name"] + tax_group_id = int(request.form["tax-group-id"]) + tax_group_description = request.form["tax-group-description"] + net_unit_price = float(request.form["net-unit-price"]) + gross_unit_price = float(request.form["gross-unit-price"]) + except: + return f"Incomplete or mistyped form", 400 + + cart = session.get("cart", default=[]) + print(cart) + cart.append({ + "barcode": barcode, + "name": name, + "sales_units": sales_units, + "group_id": group_id, + "group_name": group_name, + "location_id": location_id, + "location_name": location_name, + "tax_group_id": tax_group_id, + "tax_group_description": tax_group_description, + "net_unit_price": net_unit_price, + "gross_unit_price": gross_unit_price + }) + session["cart"] = cart + return redirect("/entry") - return redirect("/entry/select-snack-entry") + with db.run_query("entry/get_groups.sql") as cursor: + groups = cursor.fetchall() - groups = db.run_query("get_groups.sql").fetchall() - locations = db.run_query("get_locations.sql").fetchall() + with db.run_query("entry/get_locations.sql") as cursor: + locations = cursor.fetchall() - return render_template("entry/edit-item-data.html", **{ - "groups": groups, - "locations": locations, - "entry": session["entry"] - }) + with db.run_query("entry/get_tax_groups.sql") as cursor: + tax_groups = cursor.fetchall() + + return render_template( + "entry/new-order.html", + groups=groups, + locations=locations, + tax_groups=tax_groups + ) -@bp.route("/select-snack-entry", methods=["GET", "POST"]) -def edit_snack_data(): - if "entry" not in session: - return redirect("/entry/edit-item-data") - snacks = db.run_query("get_snacks_by_barcode.sql", { - "snack_barcode": session["entry"]["item_barcode"] - }).fetchall() +# API routes for interactive JS stuff - return render_template("entry/select-snack-entry.html", **{ - "entry": session["entry"], - "snacks": snacks - }) + +@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 diff --git a/jon/static/jon.css b/jon/static/jon.css index 9897996..0085d72 100644 --- a/jon/static/jon.css +++ b/jon/static/jon.css @@ -63,3 +63,11 @@ th { .form-input > select { display: block; } +.form-input.--inline { + display: inline-block; +} +.form-input.--inline > input:not([type=radio]), +.form-input.--inline > select { + display: block; + width: 8em; +} diff --git a/jon/template_utils.py b/jon/template_utils.py index b192b04..a862abb 100644 --- a/jon/template_utils.py +++ b/jon/template_utils.py @@ -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) diff --git a/jon/templates/entry/edit-item-data.html b/jon/templates/entry/edit-item-data.html deleted file mode 100644 index 37a7f54..0000000 --- a/jon/templates/entry/edit-item-data.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
{{ entry }}
- -
- Neuer Inventareintrag - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - - -
-
- - -
-
- - -
- -
-
-{% endblock %} diff --git a/jon/templates/entry/index.html b/jon/templates/entry/index.html index 587c3cb..e08c717 100644 --- a/jon/templates/entry/index.html +++ b/jon/templates/entry/index.html @@ -1,5 +1,42 @@ {% extends "base.html" %} {% block content %} -Neuer Eintrag +Neuer Auftrag +

Aufträge

+ + + + + + + + + + + + + {% for cart_item in cart %} + + + + + + + + + + + + {% endfor %} +
BarcodeNameEingekauftGruppeRaumSteuergruppeEK-Preis (Netto)VK-Preis (Brutto)Aktionen
{{ cart_item.barcode }}{{ cart_item.name }}{{ cart_item.sales_units }}{{ cart_item.group_name }} ({{ cart_item.group_id }}){{ cart_item.location_name }} ({{ cart_item.location_id }}){{ cart_item.tax_group_description }} ({{ cart_item.tax_group_id }}){{ format_currency(cart_item.net_unit_price) }}{{ format_currency(cart_item.gross_unit_price) }} +
+ + +
+
+
+ + + +
{% endblock %} diff --git a/jon/templates/entry/new-order.html b/jon/templates/entry/new-order.html new file mode 100644 index 0000000..b986866 --- /dev/null +++ b/jon/templates/entry/new-order.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block content %} +
+ + +{% endblock %} diff --git a/jon/templates/entry/select-snack-entry.html b/jon/templates/entry/select-snack-entry.html deleted file mode 100644 index 3edf71a..0000000 --- a/jon/templates/entry/select-snack-entry.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
{{ entry }}
- -
- Neuer Inventareintrag - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID{{ entry.item_id }}
Barcode{{ entry.item_barcode }}
Name{{ entry.item_name }}
Einkaufspreis (Netto){{ format_currency(entry.item_net_unit_price) }}
Empfohlener Garfield-Verkaufspreis{{ format_currency(get_garfield_price(entry.item_net_unit_price, entry.item_tax_group_id)) }}
Kaufdatum{{ format_date(entry.item_bought) }}
Gruppe{{ entry.item_group_name }} ({{ entry.item_group_id }})
Anzahl{{ entry.item_amount }}
Raum{{ entry.item_location_name }} ({{ entry.item_location_id }})
-
- -
- Snackeinträge mit Barcode {{ entry.item_barcode }} - - - - - - - - - - - - - - {% for snack in snacks %} - - - - - - - - - - - - {% endfor %} -
IDBarcodeNameVerkaufspreis (Brutto)EintragedatumSteuersatzRaumAktiv?Aktionen
{{ snack.snack_id }}{{ snack.snack_barcode }}{{ snack.snack_name }}{{ format_currency(snack.snack_price) }}{{ format_date(snack.snack_timestamp) }}{{ snack.description }} ({{ snack.tax_group_id }}){{ snack.location_name }} ({{ snack.location_id }}){{ format_bool(snack.snack_available) }} -
- - -
-
-
-{% endblock %}