Author SHA1 Message Date
paul 91157f970d Improve entry flow a little 2024-05-14 16:43:04 +02:00
paul 63ffd98ae8 Rework report 2024-05-14 00:14:24 +02:00
paul e6471070a6 Merge branch 'feature/consumption-graphs' 2024-05-13 19:32:42 +02:00
paul c6544f29c8 Add todo 2024-05-13 19:32:34 +02:00
paul bd3ca2f413 Add temporary table with index for speeding up consumption graph query 2024-05-13 19:32:34 +02:00
paul 87366336c5 Add inline SVG consumption graphs 2024-05-13 19:32:34 +02:00
Paul Brinkmeier 140b272f6c Merge dockerized into main
Reviewed-on: https://git.fsmi.org/paul/jon/pulls/6
Reviewed-by: Paul Brinkmeier <paul.brinkmeier@fsmi.uni-karlsruhe.de>
2024-02-14 16:11:37 +01:00
Shirkanesi a7461f6e2c ADD information about docker in README 2024-02-14 11:28:32 +01:00
Shirkanesi 3c07be0160 ADD Dockerfile for portability 2024-02-13 10:41:32 +01:00
paul 61cf7696a5 Merge branch 'feature/audit-icons' 2023-12-13 13:38:56 +01:00
paul 3d9ad49020 Add icons to inventory page that indicate common issues 2023-12-05 17:18:39 +01:00
paul 1b6d0c40f3 Merge branch 'feature/auth-token-flask-login' 2023-12-05 15:52:01 +01:00
paul 235ef32efd Add Arch Linux dependencies to readme 2023-12-05 15:50:27 +01:00
paul 85598adaab Use flask-login instead of client-side sessions for storing selected location and orders 2023-08-22 18:31:42 +02:00
paul 70934a2f85 Add flask-login 2023-08-22 18:31:42 +02:00
Lukas Brocke 5cec28cad1 Merge pull request 'Item and Snack Entry' (#4) from feature/entry into main
Reviewed-on: https://git.fsmi.org/paul/jon/pulls/4
2023-08-22 18:24:16 +02:00
paul d57e66c033 Add some types and doc to frontend code 2023-08-22 16:56:04 +02:00
paul d9114d21c7 Some doc stuff 2023-08-22 13:14:56 +02:00
paul 2108ef2418 Add unitsLeft field to SearchResult type 2023-08-22 13:14:56 +02:00
paul a2d1d42362 Implement adding new inventory items 2023-08-22 13:14:56 +02:00
paul 32bcb1b18a fixup! Store location name, group name and tax group description in order 2023-08-22 13:14:56 +02:00
paul 1e4aadee06 Store location name, group name and tax group description in order
Also delete compiled entry JS and add a Makefile
2023-08-22 13:14:56 +02:00
paul a82e75999b Restyle calculator 2023-08-22 13:14:56 +02:00
paul 7b95b69c7b Make it possible to delete orders from the cart 2023-08-22 13:14:56 +02:00
paul e0aef726bd Implement adding orders to the cart and viewing it 2023-08-22 13:14:56 +02:00
paul 7707a9da6d Make entry for submittable 2023-08-22 13:14:56 +02:00
paul ca2e2e2e4a Refactor calculator into its own module 2023-08-22 13:14:56 +02:00
paul 208cc54e53 Implement price calculator 2023-08-22 13:14:56 +02:00
paul f7dd572fe1 Add note about entry.js to readme 2023-08-22 13:14:56 +02:00
paul 69dba202d0 Move around gross unit price input 2023-08-22 13:14:56 +02:00
paul 3d6bd7bac5 Rewrite some of the grossUnitPrice stuff 2023-08-22 13:14:56 +02:00
paul 0f9be13e31 Refactor fronted somewhat 2023-08-22 13:14:56 +02:00
paul be86248cce First draft of elm frontend
This will probably be scrapped or rewritten
2023-08-22 13:14:54 +02:00
paul 01d060cf5f Remove old entry stuff 2023-08-22 13:14:15 +02:00
35 changed files with 1224 additions and 287 deletions
+1
View File
@@ -5,3 +5,4 @@ jon/config.json
elm-stuff/
venv/
.venv/
jon/static/*.js
+25
View File
@@ -0,0 +1,25 @@
FROM debian:latest as builder
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y elm-compiler make ca-certificates
COPY . /app
WORKDIR /app
RUN rm -rf .venv venv
RUN make frontend
FROM python:3.11-alpine as runner
COPY --from=builder /app /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN pip install gunicorn
EXPOSE 5000
ENV JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield"
ENV JON_SECRET_KEY="changeme"
CMD ["sh", "-c", "gunicorn -b '0.0.0.0:5000' --chdir /app 'jon:create_app()'"]
+6
View File
@@ -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 $@
+47 -2
View File
@@ -4,6 +4,11 @@
## Setup
`jon` is a Python WSGI application written using Flask.
This means you'll have to install a bunch of Python packages to get up and running.
### Dependencies
```
pip install -r requirements.txt
```
@@ -11,6 +16,27 @@ 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.
#### Arch Linux
If you're on Arch, these packages are required for running the server:
```
python python-flask python-flask-login python-psycopg2
```
### 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
```
@@ -19,6 +45,20 @@ flask --app jon run --debug
`--debug` restarts the server when a source file changes.
## Running with docker
When you prefer running the application using docker you can just use
```
docker compose up
```
In case your local username does not line up with your FSMI-username, you need to specify your FSMI-username
using `USER=<username>`, e.g.:
```
USER=shirkanesi docker compose up
```
This can also be persisted by following the instructions in the docker-compose.yml
## fsmi-db forward
```
@@ -27,8 +67,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 +76,10 @@ 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
- [ ] Use cool new function for deactivating items
+19
View File
@@ -0,0 +1,19 @@
---
version: '3.7'
services:
jon:
container_name: jon
build: .
ports:
- "5000:5000"
volumes:
- ~/.pgpass:/root/.pgpass:ro
dns:
- 1.1.1.1
- 8.8.8.8
network_mode: bridge
environment:
# If your local user is different from your fsmi user, change $USER here!
- JON_DB_CONNECTION_STRING="host=fsmi-db.fsmi.org dbname=garfield user=$USER"
- JON_SECRET_KEY="changemetosomethingsuperrandomandsecure"
+28
View 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": {}
}
}
+128
View File
@@ -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
+433
View File
@@ -0,0 +1,433 @@
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
, defaultLocation : Location
, groups : List Group
, defaultGroup : Group
, taxGroups : List TaxGroup
, defaultTaxGroup : 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 IEInit
| SetBarcode String
| SetName String
| SetSalesUnits String
| CalculatorMsg Calculator.Msg
| SetNetUnitPrice String
| SetGrossUnitPrice String
| SetGroup String
| SetLocation String
| SetTaxGroup String
type IEInit
= IEInitBarcode String
| IEInitSearchResult SearchResult
-- 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 (IEInitBarcode barcode) ->
( ItemEditor
{ barcode = barcode
, name = ""
, calculator = Calculator.init 0
, netUnitPrice = NumberInput.fromFloat 0
, grossUnitPrice = NumberInput.fromFloat 0
, salesUnits = NumberInput.fromInt 0
, group = Select.init (.id >> String.fromInt) (.name) globals.defaultGroup globals.groups
, location = Select.init (.id >> String.fromInt) (.name) globals.defaultLocation globals.locations
, taxGroup = Select.init (.id >> String.fromInt) (.description) globals.defaultTaxGroup globals.taxGroups
}
, Cmd.none
)
GotoItemEditor (IEInitSearchResult 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 ->
div []
[ div []
[ if model.searchTerm == ""
then button [ disabled True ] [ text "Neuer Artikel" ]
else button [ onClick <| GotoItemEditor <| IEInitBarcode model.searchTerm ] [ text <| "Neuer Artikel mit Barcode " ++ model.searchTerm ]
]
, 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 <| IEInitSearchResult 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
+29
View File
@@ -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
+36
View File
@@ -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
+2 -6
View File
@@ -20,14 +20,10 @@ def create_app():
# You don't need a config.json. If you don't provide one, default-config.json
# is used.
app.config.from_file("config.json", load=json.load, silent=True)
app.config.from_prefixed_env(prefix="JON")
db.init_app(app)
# This function denies every request until `auth.ACCESS_TOKEN`
# is passed using `?token=` to authenticate the session.
@app.before_request
def before_req_fun():
return auth.before_request()
auth.init_app(app)
@app.context_processor
def utility_processor():
+61 -18
View File
@@ -1,7 +1,10 @@
import secrets
import string
from flask import Blueprint, request, redirect, render_template, session
from flask import Blueprint, request, redirect, render_template
from flask_login import current_user, login_user, logout_user, LoginManager
from typing import Any, Dict, List, Optional
bp = Blueprint("auth", __name__, url_prefix="/auth")
@@ -15,27 +18,67 @@ ALLOWED_PATHS = [
]
def before_request():
"""
If the correct token query parameter is passed along with any request,
we mark this session authenticated by setting `session["authenticated"]`.
Unless the session is authenticated, all requests result in a 403 FORBIDDEN.
"""
if "token" in request.args:
if request.args["token"] == ACCESS_TOKEN:
session["authenticated"] = ()
# Reload the page without query parameters
return redirect(request.path)
# A poor man's replacement for memory-backed session solution.
# We keep exactly one User (and the corresponding UserData) in
# memory and use that to store session data.
class UserData:
location: Optional[Dict[str, Any]]
orders: List[Dict[str, Any]]
# Don't deny any paths in `ALLOWED_PATHS`
if request.path in ALLOWED_PATHS:
return
def __init__(self):
self.location = None
self.orders = []
if not "authenticated" in session:
return render_template("auth/denied.html"), 403
class User:
is_authenticated: bool
is_active: bool
is_anonymous: bool
data: UserData
def __init__(self):
self.is_authenticated = True
self.is_active = True
self.is_anonymous = False
self.data = UserData()
def get_id(self) -> str:
return ""
def init_app(app):
login_manager = LoginManager(app)
the_one_and_only_user = User()
@login_manager.user_loader
def load_user(user_id: str) -> User:
assert user_id == ""
return the_one_and_only_user
# This function denies every request until `auth.ACCESS_TOKEN`
# is passed using `?token=` to authenticate the user.
# We use this instead of @login_required because otherwise we'd have
# to add that annotation to all routes.
# See also: https://flask-login.readthedocs.io/en/latest/#flask_login.login_required
@app.before_request
def before_request():
if "token" in request.args:
if request.args["token"] == ACCESS_TOKEN:
login_user(the_one_and_only_user)
# Reload the page without query parameters
return redirect(request.path)
# Never deny any paths in `ALLOWED_PATHS`
if request.path in ALLOWED_PATHS:
return
if not current_user.is_authenticated:
return render_template("auth/denied.html"), 403
@bp.get("/logout")
def logout():
session.pop("authenticated", None)
logout_user()
return redirect("/")
-2
View File
@@ -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)
+54
View File
@@ -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,
@@ -57,3 +58,56 @@ FROM garfield.inventory_items
) m USING (item_id)
ORDER BY inventory_items.name;
-- How many *other* active inventory lines exist with the same barcode in the same location that are newer?
CREATE TEMPORARY VIEW more_recent_inventory_lines_with_same_barcode AS
SELECT
a.item_id,
-- It's important not to count(*) here because item_id is NULL
-- when no other inventory lines exist.
count(b.item_id) AS other_lines_count
FROM garfield.inventory_items AS a
LEFT JOIN garfield.inventory_items AS b
ON a.item_barcode = b.item_barcode
AND a.item_id != b.item_id
AND a.location = b.location
AND b.available
AND b.bought > a.bought
GROUP BY a.item_id;
-- We need to create this table so that we can put an index on it
-- Otherwise the join in the consumption graph query becomes much slower
-- Perhaps it would be nicer to use a materialized view instead
DROP TABLE IF EXISTS last_n_days;
CREATE TEMPORARY TABLE last_n_days AS (
SELECT
generate_series(now() - interval '14 days', now(), interval '1 day')::date AS sale_date
);
CREATE UNIQUE INDEX last_n_days_sale_date ON last_n_days (sale_date);
-- Get an array of how often items were sold over the last 14 days
CREATE TEMPORARY VIEW inventory_last_n_days_sales AS
WITH
sales_by_date AS (
SELECT
inventory_line AS item_id,
date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1') AS sale_date,
count(*)::int AS sales
FROM garfield.snack_sales_log
GROUP BY item_id, sale_date
),
beeg AS (
SELECT item_id, sale_date, count(snack_sales_log_timestamp)::int AS sales
FROM garfield.inventory_items
CROSS JOIN last_n_days
LEFT JOIN garfield.snack_sales_log
ON inventory_line = item_id
-- snack_sales_log has an index on snack_sales_log_timestamp to speed up this query
-- If that index doesn't exist the query takes much longer.
AND sale_date = date_trunc('day', snack_sales_log_timestamp AT TIME ZONE 'UTC+1')
WHERE available
GROUP BY item_id, sale_date
ORDER BY item_id, sale_date
)
SELECT item_id, array_agg(sales) AS last_n_days_sales
FROM beeg
GROUP BY item_id
+26
View File
@@ -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$$
@@ -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
View File
@@ -0,0 +1,4 @@
SELECT
location_id AS id,
location_name AS name
FROM garfield.locations
+9
View 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
+2
View File
@@ -1,6 +1,8 @@
SELECT
*
FROM all_inventory_item_overview
LEFT JOIN more_recent_inventory_lines_with_same_barcode USING (item_id)
LEFT JOIN inventory_last_n_days_sales USING (item_id)
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
AND available
ORDER BY
+33 -39
View File
@@ -1,49 +1,43 @@
-- parameters:
--
-- location_id: Location to generate the report for
WITH
most_recent_sales AS (
SELECT DISTINCT ON (inventory_line)
inventory_line, snack_sales_log_id, snack_sales_log_timestamp AS most_recent_sale
sales_by_item_id AS (
SELECT
inventory_line AS item_id,
max(snack_sales_log_timestamp) AS last_sold,
count(*)::int AS units_sold
FROM garfield.snack_sales_log
ORDER BY inventory_line ASC, snack_sales_log_timestamp DESC
WHERE type_id = 'SNACK_BUY'
AND inventory_line IS NOT NULL
AND snack_sales_log_timestamp > NOW() - INTERVAL '120 days'
AND location_id = %(location_id)s
GROUP BY item_id
),
enhanced_overview1 AS (
sales_by_barcode AS (
SELECT
inventory_items.item_id,
inventory_items.item_barcode,
inventory_items.name,
units_left,
inventory_items.sales_units,
correction_delta,
location_name,
location,
CASE
WHEN snack_sales_log_id IS NULL THEN 0
ELSE sales / (EXTRACT(EPOCH FROM (
CASE
WHEN units_left <= 0 THEN most_recent_sale
ELSE NOW()
END
)) - EXTRACT(EPOCH FROM bought)) * 24 * 3600
END AS per_day
FROM garfield.inventory_item_overview
item_barcode,
max(name) AS name,
sum(units_sold) AS units_sold,
round(avg((units_sold / extract(epoch FROM (CASE WHEN available THEN now() ELSE last_sold END) - bought)) * 86400)::numeric, 2) AS sold_per_day
FROM sales_by_item_id
LEFT JOIN garfield.inventory_items USING (item_id)
LEFT JOIN most_recent_sales ON item_id = inventory_line
GROUP BY item_barcode
),
enhanced_overview2 AS (
current_inventory AS (
SELECT
*,
CASE
WHEN per_day = 0 THEN NULL
ELSE GREATEST(0, units_left / per_day)
END AS days_left
FROM enhanced_overview1
item_barcode,
sum(units_left)::int AS units_left
FROM all_inventory_item_overview
WHERE available
AND location = %(location_id)s
GROUP BY item_barcode
)
SELECT
*,
CASE
WHEN days_left IS NULL THEN NULL
ELSE GREATEST(0, (60 - days_left) * per_day)
END AS for_two_months
FROM enhanced_overview2
WHERE (%(location_id)s IS NULL OR location = %(location_id)s)
ORDER BY days_left ASC, per_day DESC
sales_by_barcode.*,
COALESCE(current_inventory.units_left, 0)::int AS units_left
FROM sales_by_barcode
LEFT JOIN current_inventory USING (item_barcode)
ORDER BY units_sold DESC
+17
View File
@@ -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
+107 -40
View File
@@ -1,8 +1,5 @@
import datetime
import zoneinfo
from flask import Blueprint, redirect, render_template, request, session
from flask import Blueprint, flash, redirect, render_template, request
from flask_login import current_user
from . import db
@@ -10,50 +7,120 @@ 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")
return render_template(
"entry/index.html"
)
@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():
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 = current_user.data.orders
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
current_user.data.orders = []
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
del current_user.data.orders[order_index]
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
return redirect("/entry/select-snack-entry")
current_user.data.orders.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
})
return redirect("/entry")
groups = db.run_query("get_groups.sql").fetchall()
locations = db.run_query("get_locations.sql").fetchall()
with db.run_query("entry/get_groups.sql") as cursor:
groups = cursor.fetchall()
return render_template("entry/edit-item-data.html", **{
"groups": groups,
"locations": locations,
"entry": session["entry"]
})
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()
selected_location = current_user.data.location
return render_template(
"entry/new-order.html",
groups=groups,
locations=locations,
default_location=next(location for location in locations if location["id"] == selected_location["location_id"]) if selected_location is not None else locations[0],
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 = current_user.data.location
with db.run_query("search_items.sql", {
"location_id": location["location_id"] if location else None,
"search_term": search_term
}) as cursor:
items = cursor.fetchall()
return items
+11 -4
View File
@@ -1,4 +1,5 @@
from flask import Blueprint, redirect, render_template, request, session
from flask import Blueprint, redirect, render_template, request
from flask_login import current_user
from . import db
@@ -8,7 +9,7 @@ bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@bp.get("/")
def index():
location = session.get("location", None)
location = current_user.data.location
items = db.run_query("get_inventory_overview.sql", {
"location_id": None if location is None else location["location_id"]
}).fetchall()
@@ -20,12 +21,18 @@ def index():
@bp.get("/report")
def read_report():
location = session.get("location", None)
location = current_user.data.location
if location is None:
# TODO: Error handling
return "please select a location in order to generate a report", 400
items = db.run_query("get_inventory_report.sql", {
"location_id": None if location is None else location["location_id"]
"location_id": location["location_id"]
}).fetchall()
return render_template("inventory/read_report.html", **{
"location": location,
"items": items
})
+6 -6
View File
@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, session
from flask import Blueprint, render_template, request
from flask_login import current_user
from . import db
@@ -11,13 +12,12 @@ def index():
if request.method == "POST":
location_id = request.form.get("location_id", "")
if location_id == "":
session.pop("location", None)
current_user.data.location = None
else:
location = db.run_query("get_location_by_id.sql", {
"location_id": location_id}
).fetchone()
session["location"] = location
"location_id": location_id
}).fetchone()
current_user.data.location = location
locations = db.run_query("get_locations.sql").fetchall()
+22
View File
@@ -40,6 +40,9 @@ nav > ul > li + li:before {
.--centered {
text-align: center;
}
.--not-important {
color: #aaa;
}
@keyframes wiggle {
0%, 100% { margin-top: 0; }
50% { margin-top: -0.5em; }
@@ -55,6 +58,10 @@ th {
body {
font-size: 8px;
}
/* hide the menu when printing */
header {
display: none;
}
}
.form-input > label {
font-size: .8em;
@@ -63,3 +70,18 @@ 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;
}
details {
font-size: 0.8em;
}
.consumption-graph {
display: block;
height: 1em;
}
+3 -14
View File
@@ -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)
+13 -2
View File
@@ -12,13 +12,14 @@
<ul>
<li{{ " class=current-page" if request.path == "/" else "" }}><a href="/">Home</a></li>
<li{{ " class=current-page" if request.path.startswith("/inventory") else "" }}><a href="/inventory">Inventar</a></li>
<li><a href="/inventory/report">Einkäuferbericht</a></li>
<li{{ " class=current-page" if request.path.startswith("/entry") else "" }}><a href="/entry">Eintragen</a></li>
<li{{ " class=current-page" if request.path.startswith("/location") else "" }}>
<a href="/location">
{% if "location" not in session %}
{% if not current_user.data.location %}
Raum wählen
{% else %}
Raum: {{ session.location.location_name }}
Raum: {{ current_user.data.location.location_name }}
{% endif %}
</a>
</li>
@@ -30,6 +31,16 @@
<details>
<summary><code>config</code></summary>
<pre>{% for key, value in config.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
<details>
<summary><code>session</code></summary>
<pre>{% for key, value in session.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
<details>
<summary><code>current_user.data</code></summary>
<pre>{% for key, value in current_user.data.__dict__.items() %}{{ key }} = {{ value }}
{% endfor %}</pre>
</details>
{% endif %}
-56
View File
@@ -1,56 +0,0 @@
{% extends "base.html" %}
{% block content %}
<pre>{{ entry }}</pre>
<fieldset>
<legend>Neuer Inventareintrag</legend>
<form method="POST" action="/entry/edit-item-data">
<div class="form-input">
<label for="item_bought">Kaufdatum</label>
<input name="item_bought" id="item_bought" type="date" value="{{ (entry.item_bought or now()).strftime('%Y-%m-%d') }}">
</div>
<div class="form-input">
<label for="item_barcode">Barcode</label>
<input name="item_barcode" id="item_barcode" type="text" value="{{ entry.item_barcode }}" placeholder="Barcode">
</div>
<div class="form-input">
<label for="item_name">Artikel</label>
<input name="item_name" id="item_name" type="text" value="{{ entry.item_name }}" placeholder="Artikel">
</div>
<div class="form-input">
<label for="item_group">Gruppe</label>
<select name="item_group" id="item_group">
{% for group in groups %}
<option value="{{ group.group_id }}"{% if entry.item_group_id == group.group_id %} selected{% endif %}>{{ group.group_name }} ({{ group.group_id }})</option>
{% endfor %}
</select>
</div>
<div class="form-input">
<label for="item_net_unit_price">Stückpreis (Netto) in €</label>
<input name="item_net_unit_price" id="item_net_unit_price" type="number" step="0.01" value="{{ entry.item_net_unit_price }}" placeholder="Stückpreis (Netto) in €">
</div>
<div class="form-input">
<input name="item_tax_group" id="item_tax_group_1" type="radio" value="1"{% if entry.item_tax_group_id == 1 %} selected{% endif %}>
<label for="item_tax_group_1">Volle Umsatzsteuer (19%)</label>
<input name="item_tax_group" id="item_tax_group_2" type="radio" value="2"{% if entry.item_tax_group_id == 2 %} selected{% endif %}>
<label for="item_tax_group_2">Ermäßigte Umsatzsteuer (7%)</label>
</div>
<div class="form-input">
<label for="item_amount">Anzahl</label>
<input name="item_amount" id="item_amount" type="number" value="{{ entry.item_amount }}" placeholder="Anzahl">
</div>
<div class="form-input">
<label for="item_group">Raum</label>
<select name="item_location" id="item_location">
{% for location in locations %}
<option value="{{ location.location_id }}"{% if entry.item_location_id == location.location_id or ("item_location" not in entry and (session.location.location_id == location.location_id)) %} selected{% endif %}>{{ location.location_name }} ({{ location.location_id }})</option>
{% endfor %}
</select>
</div>
<button>Weiter zu den Snackeinträgen</button>
</form>
</fieldset>
{% endblock %}
+38 -1
View File
@@ -1,5 +1,42 @@
{% extends "base.html" %}
{% block content %}
<a href="/entry/edit-item-data">Neuer Eintrag</a>
<a href="/entry/new-order">Neuer Auftrag</a>
<h2>Aufträge</h2>
<table>
<tr>
<th>Barcode</th>
<th>Name</th>
<th>Eingekauft</th>
<th>Gruppe</th>
<th>Raum</th>
<th>Steuergruppe</th>
<th>EK-Preis (Netto)</th>
<th>VK-Preis (Brutto)</th>
<th>Aktionen</th>
</tr>
{% for cart_item in current_user.data.orders %}
<tr>
<td><code>{{ cart_item.barcode }}</code></td>
<td>{{ cart_item.name }}</td>
<td class="--align-right">{{ cart_item.sales_units }}</td>
<td class="--align-right">{{ cart_item.group_name }} ({{ cart_item.group_id }})</td>
<td class="--align-right">{{ cart_item.location_name }} ({{ cart_item.location_id }})</td>
<td class="--align-right">{{ cart_item.tax_group_description }} ({{ cart_item.tax_group_id }})</td>
<td class="--align-right">{{ format_currency(cart_item.net_unit_price) }}</td>
<td class="--align-right">{{ format_currency(cart_item.gross_unit_price) }}</td>
<td>
<form method="POST" action="/entry/delete-order">
<input type="hidden" name="order-index" value="{{ loop.index0 }}">
<button>Löschen</button>
</form>
</td>
</tr>
{% endfor %}
</table>
<form method="POST" action="/entry/add-new-items">
<input type="checkbox" name="i-know-what-im-doing" id="i-know-what-im-doing">
<label for="i-know-what-im-doing">I weiß, was ich tue</label>
<button>Neue Einträge anlegen</button>
</form>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<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 }},
defaultLocation: {{ to_json(default_location) | safe }},
groups: {{ to_json(groups) | safe }},
defaultGroup: {{ to_json(groups[0]) | safe }},
taxGroups: {{ to_json(tax_groups) | safe }},
defaultTaxGroup: {{ to_json(tax_groups[0]) | safe }},
}
});
</script>
{% endblock %}
@@ -1,83 +0,0 @@
{% extends "base.html" %}
{% block content %}
<pre>{{ entry }}</pre>
<fieldset>
<legend>Neuer Inventareintrag</legend>
<table>
<tr>
<th class="--align-left">ID</th>
<td>{{ entry.item_id }}</td>
</tr>
<tr>
<th class="--align-left">Barcode</th>
<td><code>{{ entry.item_barcode }}</code></td>
</tr>
<tr>
<th class="--align-left">Name</th>
<td>{{ entry.item_name }}</td>
</tr>
<tr>
<th class="--align-left">Einkaufspreis (Netto)</th>
<td>{{ format_currency(entry.item_net_unit_price) }}</td>
</tr>
<tr>
<th class="--align-left">Empfohlener Garfield-Verkaufspreis</th>
<td>{{ format_currency(get_garfield_price(entry.item_net_unit_price, entry.item_tax_group_id)) }}</td>
</tr>
<tr>
<th class="--align-left">Kaufdatum</th>
<td>{{ format_date(entry.item_bought) }}</td>
</tr>
<tr>
<th class="--align-left">Gruppe</th>
<td>{{ entry.item_group_name }} ({{ entry.item_group_id }})</td>
</tr>
<tr>
<th class="--align-left">Anzahl</th>
<td>{{ entry.item_amount }}</td>
</tr>
<tr>
<th class="--align-left">Raum</th>
<td>{{ entry.item_location_name }} ({{ entry.item_location_id }})</td>
</tr>
</table>
</fieldset>
<fieldset>
<legend>Snackeinträge mit Barcode <code>{{ entry.item_barcode }}</code></legend>
<table>
<tr>
<th>ID</th>
<th>Barcode</th>
<th>Name</th>
<th>Verkaufspreis (Brutto)</th>
<th>Eintragedatum</th>
<th>Steuersatz</th>
<th>Raum</th>
<th>Aktiv?</th>
<th>Aktionen</th>
</tr>
{% for snack in snacks %}
<tr>
<td>{{ snack.snack_id }}</td>
<td><code>{{ snack.snack_barcode }}</code></td>
<td>{{ snack.snack_name }}</td>
<td class="--align-right">{{ format_currency(snack.snack_price) }}</td>
<td>{{ format_date(snack.snack_timestamp) }}</td>
<td>{{ snack.description }} ({{ snack.tax_group_id }})</td>
<td>{{ snack.location_name }} ({{ snack.location_id }})</td>
<td>{{ format_bool(snack.snack_available) }}</td>
<td>
<form method="POST" action="/entry/select-snack-entry">
<input type="hidden" name="snack_id" value="{{ snack.snack_id }}">
<button>Snackeintrag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</fieldset>
{% endblock %}
+30
View File
@@ -1,9 +1,34 @@
{% macro consumption_graph_svg(values) -%}
{% set stroke_width = 8 %}
{% set width = 300 %}
{% set height = 100 %}
{% set padding = 4 %}
{% set dx = (width - 2 * padding) / ((values | length) + 1) %}
{% set dy = (height - 2 * padding) / ((values + [1]) | max) %}
<svg viewBox="0 0 {{ width }} {{ height }}" role="img" class="consumption-graph">
<polyline
points="
{% for value in values %}
{{ padding + loop.index * dx }}, {{ height - padding - value * dy }}
{% endfor %}
"
stroke="green"
stroke-width="{{ stroke_width }}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
>
</svg>
{% endmacro -%}
{% extends "base.html" %}
{% block content %}
<table>
<tr>
<th>Stat</th>
<th>ID</th>
<th>Graph</th>
<th>Barcode</th>
<th>Name</th>
<th>Preis (Netto)</th>
@@ -17,7 +42,12 @@
</tr>
{% for item in items %}
<tr>
<td>
{% if item.units_left == 0 %}<span title="Leerer aktiver Inventareintrag">🅾️</span>{% endif %}
{% if item.other_lines_count != 0 %}<span title="{% if item.other_lines_count == 1 %}Neuerer Eintrag mit demselben Barcode ist aktiv{% else %}{{ item.other_lines_count }} Einträge mit demselben Barcode sind aktiv{% endif %}">🔄</span>{% endif %}
</td>
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
<td>{{ consumption_graph_svg(item.last_n_days_sales) }}</td>
<td><code>{{ item.item_barcode }}</code></td>
<td>{{ item.name }}</td>
<td class="--align-right">{{ format_currency(item.unit_price) }}</td>
+10 -10
View File
@@ -1,29 +1,29 @@
{% extends "base.html" %}
{% block content %}
<h2>Einkäuferbericht für {{ location.location_name }}</h2>
<table>
<tr>
<th>ID</th>
<th>Barcode</th>
<th>Name</th>
<th>Inventar</th>
<th>Gesamt</th>
<th>Raum</th>
<th title="In den letzten 4 Monaten verkauft">Verkauft</th>
<th>Verbrauch [1/d]</th>
<th>Verbrauch [1/60d]</th>
<!--
<th title="Estimated Time Until Empty">ETUE [d]</th>
<th>Für 2m</th>
-->
</tr>
{% for item in items %}
<tr>
<td><a href="/inventory/item/{{ item.item_id }}">{{ item.item_id }}</a></td>
<tr{% if item.units_left >= item.units_sold %} class="--not-important"{% endif %}>
<td><code>{{ item.item_barcode }}</code></td>
<td>{{ item.name }}</td>
<td class="--align-right">{{ item.units_left }}</td>
<td class="--align-right">{{ item.sales_units + item.correction_delta }}</td>
<td>{{ item.location_name }}</td>
<td class="--align-right">{{ item.per_day|round(2) }}</td>
<td class="--align-right">{% if item.days_left != None %}{{ item.days_left|round(1) }}{% endif %}</td>
<td class="--align-right">{% if item.for_two_months %}{{ item.for_two_months|round(1) }}{% endif %}</td>
<td class="--align-right">{{ item.units_sold }}</td>
<td class="--align-right">{{ item.sold_per_day }}</td>
<td class="--align-right">{{ item.sold_per_day * 60 }}</td>
</tr>
{% endfor %}
</table>
+2 -2
View File
@@ -3,9 +3,9 @@
{% block content %}
<form method="POST">
<select name="location_id">
<option value="" {{ "selected" if "location" not in session else ""}}>-</option>
<option value="" {{ "selected" if not current_user.data.location else ""}}>-</option>
{% for location in locations %}
<option value="{{ location.location_id }}" {{ "selected" if "location" in session and session.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
<option value="{{ location.location_id }}" {{ "selected" if current_user.data.location.location_id == location.location_id else "" }}>{{ location.location_name }}</option>
{% endfor %}
</select>
+1
View File
@@ -1,6 +1,7 @@
blinker==1.6.2
click==8.1.3
Flask==2.3.2
Flask-Login==0.6.2
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.2