198 Commits
Author SHA1 Message Date
nikolark 6f6fbc648c ✨ Add stats for all purchases over time 2022-02-19 22:45:47 +01:00
nikolark 7474885f14 ✨ User product purchases over time 2022-02-19 22:21:11 +01:00
nikolark f3a6e563bb ➕ Add @nivo/line 2022-02-19 17:35:19 +01:00
nikolark af45b29b1c ✨ Link to create user form 2022-02-19 17:33:02 +01:00
nikolark 64eb6c717c ✨ Add link to legacy admin 2022-02-19 17:30:12 +01:00
nikolark 2e5fbb6fa6 ✨ Split out stats into multiple sub views 2022-02-19 17:21:44 +01:00
nikolark 158f5c5652 📝 Update TODOs 2022-02-19 16:11:21 +01:00
nikolark cfd0d94537 ✨ Working user create form 2022-02-19 15:47:38 +01:00
nikolark b5c9094b64 ✨ Add user register mutation
🔥 Remove a bunch of legacy code
2022-02-19 15:26:30 +01:00
nikolark a47de48243 🔧 Misc 2022-02-19 14:32:33 +01:00
nikolark 41638e605f ♻ Split types, mutations, queries by model 2022-02-19 14:31:05 +01:00
Nikolai R Kristiansen 3274cb96c0 🚧 Start on UserCreate 2022-02-17 09:58:12 +01:00
Nikolai R Kristiansen 5f332cd365 🏷 Simplify types 2022-02-14 10:22:01 +01:00
Nikolai R Kristiansen aa66694fd5 ♻ Refactor Deposit and Register
🏷 Pick a part query types to simplify typing and fix
incompatible types
2022-02-14 10:19:34 +01:00
Nikolai R Kristiansen 8b0aaa287e 🌐 Norwegian formatting 2022-02-12 13:08:19 +01:00
Nikolai R Kristiansen 2e8b732798 💄 Bold numbers 2022-02-12 13:07:00 +01:00
Nikolai R Kristiansen 87396874c9 🔥 Remove legacy profile 2022-02-12 13:05:09 +01:00
Nikolai R Kristiansen e2a84c25a6 ✨ UserDetail and UserList views 2022-02-12 12:58:04 +01:00
Nikolai R Kristiansen ec0549154d ⬆ Bump node deps 2022-02-12 11:39:36 +01:00
Nikolai R Kristiansen ad96873493 🐛 Week starts on monday, not sunday 2022-02-10 09:47:01 +01:00
Nikolai R Kristiansen 9fa6cfe0ee 🔥 Delete legacy register and deposit
♻ Replace facebook avatar related stuff with gravatars
♻ Replace format-strings with f-strings
✨ Add profile_images command
2022-02-10 09:42:57 +01:00
Nikolai R Kristiansen a9f81737d5 ♻ Refactor Bar charts 2022-02-08 22:10:27 +01:00
Nikolai R Kristiansen 5f7a4e6ff2 ♻ Refactor orderStats
✏ Fix daily typo
2022-02-06 22:27:52 +01:00
Nikolai R Kristiansen d5ed841c9c ⬆ Bump dev deps 2022-02-06 22:16:04 +01:00
Nikolai R Kristiansen 9c7fe11bfc 🚸 Stats: Only show active products 2022-02-06 22:11:14 +01:00
Nikolai R Kristiansen df3aff7fb2 💄 Pointer cursor for pagination links 2022-02-06 22:09:24 +01:00
Nikolai R Kristiansen 264e189698 🔥 remove drf from django settings 2022-02-06 22:05:38 +01:00
Nikolai R Kristiansen 7ba3ead688 🔥 Remove stat and log views
➖ Remove djangorestframework
2022-02-06 21:59:13 +01:00
Nikolai R Kristiansen d7513f389e ✨ Make python aware stat and log are SPA views 2022-02-06 21:48:33 +01:00
Nikolai R Kristiansen bd8dbd750a 🏷 Make tsc happy 2022-02-06 21:45:11 +01:00
Nikolai R Kristiansen 7aea1ead45 🐛 Active styles for log and stats 2022-02-06 21:10:37 +01:00
Nikolai R Kristiansen 771e0c2a3b ✨ Add order stats 2022-02-06 21:05:40 +01:00
Nikolai R Kristiansen 6fa6bf1334 💄 Improve product pies 2022-02-06 16:31:18 +01:00
Nikolai R Kristiansen 9e80669a1f ✨ Add some pies 2022-02-06 12:02:09 +01:00
Nikolai R Kristiansen aead00a893 ✨ Add basic pagination in log view 2022-02-05 22:23:24 +01:00
Nikolai R Kristiansen e240eda1cb 🚸 Format datetimes 2022-02-05 22:02:33 +01:00
Nikolai R Kristiansen 1bd8fddf73 ✨ Sort orders and transactions newest first
🔧 Don't run linting using pipenv
2022-02-05 21:56:56 +01:00
Nikolai R Kristiansen ed99dadcb0 ⬆ Bump frontend deps
♻ Replace vite svg plugin
react-router v6 fixes
2022-02-05 21:44:25 +01:00
Nikolai R Kristiansen 87e2f2292c 👷 Deploy from CI using SSH 2022-02-05 21:10:14 +01:00
Nikolai R Kristiansen a2cfd0ceaf ✨ Start log view with orders & transactions 2022-02-02 23:06:33 +01:00
Nikolai R Kristiansen 85698deba5 🔇 Remove deprecated url usage 2022-01-26 23:42:37 +01:00
Nikolai R Kristiansen 1bd06b7e81 ♻️ Remove test helper 2022-01-26 23:41:13 +01:00
Nikolai R Kristiansen 6791000dea 🔇 Stop using deprecated translation functions 2022-01-26 23:27:49 +01:00
Nikolai R Kristiansen d3016a3925 ✨ Make the SPA load for home and register
Link to legacy routes
2022-01-26 23:23:33 +01:00
Nikolai R Kristiansen 717752ddae 🚨 Lint on CI 2022-01-16 22:53:29 +01:00
Nikolai R Kristiansen dcd8edaaa6 🚨 Add pre-commit
Format code
Add bin/run
2022-01-16 22:49:21 +01:00
nikolark 9d3afadbff ✅ Add basic create order test
🎨 Add MutationTest helper to help write mutation tests with strawberry
➕ add pytest-django
⬆ Bump some python deps
2022-01-16 22:30:47 +01:00
nikolark 549b0cfdd6 Update .gitlab-ci.yml file 2021-12-05 00:13:50 +00:00
nikolark 35d4334627 Update .gitlab-ci.yml file 2021-12-05 00:11:18 +00:00
nikolark 5969e70e89 Update .gitlab-ci.yml file 2021-12-05 00:05:57 +00:00
nikolark db7bdd7181 🚛 Move CI config in the right place 2021-12-05 00:49:03 +01:00
nikolark dbda43077e ✨ Make new frontend available under /modern
👷 Start adding deploy config and CI
2021-12-05 00:45:36 +01:00
nikolark bc4414473c ✨ Search as you type
🐛 Fix regression in register mutation
2021-12-04 23:48:36 +01:00
nikolark 2bcc993aaa ✨ Show details about purchase
🐛 Update user balance
2021-12-04 23:30:44 +01:00
nikolark 3d93a645ad ✨ Add basic deposit functionality 2021-11-18 22:58:32 +01:00
nikolark 40b3cb119f 🚸 Make errors red 2021-11-18 22:58:30 +01:00
Nikolai Kristiansen 9576becb62 🚨 Lint styles 2021-11-13 11:48:01 +01:00
Nikolai Kristiansen cf9c23bf7b ⬆️. Bump deps
🛠 Configure npm and node versions
2021-11-13 11:48:01 +01:00
nikolark 576392ba5f 🚸 improve error handling 2021-11-07 17:24:10 +01:00
nikolark 0a435d3224 ✨ Start on error handling
♻ Use TypedDocumentNode's instead of apollo-react-typescript codegen
💄 Add toast
2021-11-06 21:49:28 +01:00
nikolark ed82ca3891 💄 Fixed grid users 2021-11-02 22:34:55 +01:00
nikolark c44e582706 💄 Working register styles 2021-11-02 22:32:50 +01:00
nikolark d5bff90ee3 ✨ Register: working order sum logic
💄 Add more icons and load them as react components
2021-11-01 20:12:30 +01:00
nikolark d86afd9392 ✨ Add product images and basic nav 2021-10-30 19:05:50 +02:00
nikolark 350d31d8d8 ✨ Dont show inactive users in register view 2021-10-30 18:21:16 +02:00
nikolark 46c52b7618 ⬆ Bump deps 2021-10-30 17:48:03 +02:00
nikolark 2ed0c57b97 🚨 Add eslint and stylelint and format 2021-10-30 17:42:21 +02:00
nikolark f0656b34e9 ✨ Register: Show list of active products
🛠 Generate apollo client typescript helpers from graphql schema
2021-10-30 17:29:16 +02:00
nikolark fa89353525 ✨ Add basic graphql API using strawberry 🍓
✨ Add allProducts query
2021-10-30 16:54:28 +02:00
nikolark e2db9052bc ➕ Add pytest and black 2021-10-30 16:05:14 +02:00
Nikolai R Kristiansen aef0509056 🚧 Start adding new frontend boilerplate 2021-10-30 15:53:52 +02:00
nikolark 0e4ef1b75f 🔥 Remove widget_tweaks and bootstrapform use 2021-10-30 15:50:42 +02:00
nikolark 49870595b0 🔥 Remove legacy sentry client 2021-10-30 15:44:43 +02:00
nikolark 799548a90b ♻ Start using poetry 2021-10-30 15:38:36 +02:00
Nikolai R Kristiansen 2a3188667f 🐛 Use correct template tag 2021-10-27 19:25:58 +02:00
Nikolai R Kristiansen a052397990 🚨 format python code using black 2021-10-27 19:22:25 +02:00
Nikolai R Kristiansen 9c4a5b3d84 🔥 Remove legacy stuff 2021-10-27 19:18:07 +02:00
Nikolai R Kristiansen 1a27e3a521 🐛 fix migrations 2021-10-27 19:15:26 +02:00
Nikolai R Kristiansen d311e65058 ⬆️ Major bumps and django checks
Notably to django 3.2
2021-10-27 19:12:07 +02:00
Nikolai R Kristiansen a1437ab5d2 ⬆️ bump deps 2018-08-12 22:14:11 +02:00
Nikolai R Kristiansen 079af129d7 Minor fixes 2018-03-17 14:05:37 +01:00
Nikolai R Kristiansen 90eae21bc7 Add sentry and fabfile 2018-03-17 13:59:04 +01:00
Nikolai R Kristiansen cd11bd580b ⬆️ Bump most deps 2018-03-17 13:41:19 +01:00
Nikolai R Kristiansen 72562c931d Try again 2017-09-27 22:04:27 +02:00
Nikolai R Kristiansen a84c94e23b Highcharts is back 2017-09-27 22:01:15 +02:00
Nikolai R Kristiansen 16bc430369 Remove bower, bump some deps, fix log img size 2017-09-27 21:59:17 +02:00
Nikolai R Kristiansen 8fb2389d2c Fix some Django 1.11 stuff 2017-09-27 21:15:27 +02:00
Nikolai R Kristiansen c1ef6f6678 pur 2017-09-27 20:59:30 +02:00
Toralf Neby Berge f0e6331630 fix 2017-05-03 22:15:00 +02:00
Toralf Neby Berge 505b9d73d5 majur bug in admin interface 2017-05-03 21:41:28 +02:00
nikolark 2100408b72 Cheat 2016-04-13 21:20:26 +02:00
nikolark 8a4a9fcb26 User creation fixes 2016-04-13 21:17:02 +02:00
nikolark 21c4f11ebc go back 2015-11-30 11:31:00 +01:00
nikolark 35835fc64f ? 2015-11-30 11:29:25 +01:00
nikolark 6e11410972 Open up product stats 2015-11-30 11:24:29 +01:00
nikolark 63f0056898 Oops 2015-11-29 18:56:20 +01:00
nikolark e260b3d5bd Merge branch 'restfest' 2015-11-29 18:54:02 +01:00
nikolark 17545b5261 More stats! 2015-11-29 18:51:31 +01:00
nikolark 6560d3e89b Thumbnail products 2015-11-29 17:00:43 +01:00
nikolark 31cffdb24b Style fixes 2015-11-29 16:35:20 +01:00
nikolark 2b5c3529d6 Initial attempt at using Service Worker
- Caches facebook profile pictures and static resources
- Removes django-compressor and commited bower_components
- rest fixes
2015-11-29 16:17:25 +01:00
nikolark 195c49b0fd Moves product stat view logic to serializer
- Split frontend into multiple files
- Bump frontend deps
- Remove unused stuff
2015-11-28 21:21:58 +01:00
nikolark ff72b8dd0b Allow exact purchases 2015-11-25 20:31:21 +01:00
nikolark 3b7980ffe9 Start using rest framework 2015-11-25 20:29:25 +01:00
nikolark de85988039 Minor 2015-11-22 12:54:47 +01:00
nikolark ec638d51f9 Python 3 fixes 2015-11-22 12:51:12 +01:00
nikolark b69f917d57 Lazy trans 2015-11-21 21:47:47 +01:00
nikolark 42e25f57a4 gunicorn dep 2015-11-21 21:38:18 +01:00
nikolark 3bb9040797 Python 3, cleanups, fix register page logic 2015-11-21 21:14:09 +01:00
nikolark b47ee8f832 Upgrade to django 1.8, ++ 2015-11-17 13:12:37 +01:00
nikolark 8c004276a7 Fix x+1 error in order purchase 2015-11-04 21:56:58 +01:00
nikolark 0296617746 Ignore uploads 2015-10-01 09:35:18 +02:00
nikolark a858158316 Bump version of sass 2015-09-30 21:08:55 +02:00
nikolark 52d327ec83 64px width on products 2015-09-30 21:00:10 +02:00
nikolark 49dfec0c70 Adds settings for uploded files 2015-09-30 20:50:19 +02:00
nikolark 44d76dae0b I want the shell_plus management command 2015-08-24 22:06:32 +02:00
nikolark cf560a8d3d Minor 2015-08-24 22:04:11 +02:00
nikolark 8d2e0ea396 Rebuild js and remove req 2015-08-24 21:50:58 +02:00
nikolark 4488acdfcb Profile pic sync from KAK FB group is improved
PS: Tried using windows, are line endings borked?
2015-08-24 21:44:29 +02:00
nikolark 14c57bf9eb Move some files around, local_settings.py, gogo 2015-01-08 01:48:49 +01:00
nikolark 292235858e quickfix inventory view 2014-10-17 19:49:38 +02:00
nikolark c7fb6102b0 Oops 2014-10-11 17:06:29 +02:00
nikolark f8a94b7bee Adds initial profile page 2014-10-11 17:03:58 +02:00
nikolark fc65e15d98 Oops 2014-10-06 23:06:42 +02:00
nikolark acae7f51fe Dont inline css 2014-10-06 23:05:40 +02:00
nikolark 8d30585cdb Gulp and bower++, could break stuff 2014-10-06 22:52:27 +02:00
nikolark 3727f1c14a Compress resources 2014-10-06 20:15:03 +02:00
nikolark a2e7c595c1 Oops 2014-10-06 20:02:29 +02:00
nikolark d8a750151b User menu, css cleanups 2014-10-06 20:01:52 +02:00
nikolark 9641b66346 Correctly update order total 2014-10-06 13:33:23 +02:00
nikolark ea7174815b Adds external user sale 2014-10-05 23:39:18 +02:00
nikolark 1a68e4b95b PEP8 party 2 2014-10-01 17:37:08 +02:00
nikolark 4b3ffeda3c PEP8 party 2014-10-01 17:33:01 +02:00
nikolark 85218c6120 Specify sale value in inventory 2014-07-28 13:19:15 +02:00
nikolark 18012d081a Remove debug 2014-07-24 00:28:27 +02:00
nikolark 1bc3e5983d Adds new requirements 2014-07-23 22:44:13 +02:00
nikolark 32e41c05ae Adds initial inventory management, Django 1.6, ++
Adds dep on south
2014-07-23 22:39:44 +02:00
nikolark 09d41ba682 lalala 2014-04-09 20:30:25 +02:00
nikolark 36c7192d73 Fatøl 2014-04-09 20:20:34 +02:00
Paal Braathen a6faa4ff96 fix lol 2014-02-19 22:48:49 +01:00
Paal Braathen efc1e39692 Fixing obvious bugs 2014-02-19 22:25:11 +01:00
nikolark dac8319252 fuckup. 2014-02-09 15:26:01 +01:00
nikolark 532cab2c6a Fixes manual update of profile image 2014-02-09 15:22:25 +01:00
nikolark cb53489f86 Fixes unicode strings 2014-02-09 15:05:49 +01:00
nikolark 921dc92b07 /sigh 2014-02-07 10:43:53 +01:00
nikolark 0e7cb37503 Oops2 2014-02-05 22:54:47 +01:00
nikolark e071c9f360 Oops 2014-02-05 22:54:25 +01:00
nikolark 8aed5ace21 Limit deposits by perm (default off) 2014-02-05 22:51:28 +01:00
nikolark 2ef30085d6 Session settings 2014-02-05 19:45:33 +01:00
nikolark d02d22f52d Small fixes 2014-02-05 19:37:20 +01:00
nikolark 52629901e7 FB:Minor fix 2014-02-04 09:39:59 +01:00
nikolark 9864acb26b FB username: Picture preview 2014-02-03 23:38:55 +01:00
nikolark 7b042ee9fb Minor 2014-02-03 13:24:49 +01:00
nikolark 4652736f72 Minor 2014-02-03 10:22:16 +01:00
nikolark 75001f0723 Adds Facebook username picture check 2014-02-03 00:24:52 +01:00
nikolark e079ad685f JS Cleanups and misc 2014-02-02 22:20:35 +01:00
nikolark 2295455375 Kærlighed 2014-02-02 19:23:45 +01:00
nikolark 00959c73b1 User menu, various fixes 2014-02-02 19:02:16 +01:00
nikolark b7b0238871 Adds create user view, minor fixes 2014-02-02 16:52:15 +01:00
nikolark 7f84c936ab Bootstrap v3, edge case fixes 2013-12-02 10:31:18 +01:00
nikolark 3bb521db39 TODO 2013-12-02 00:07:41 +01:00
nikolark fa4f8b1f05 Hide more users by default 2013-09-22 23:40:27 +02:00
nikolark ca1b25afd9 Compact display, show less people 2013-09-22 19:49:13 +02:00
nikolark 3ae6a0675a Filtering fixes 2012-11-11 00:06:12 +01:00
nikolark 3e11ea9b75 Filtering av users 2012-11-10 23:40:16 +01:00
nikolark 368228e5fa Newer bootstrap w/ fixes 2012-11-10 23:26:59 +01:00
nikolark 32bb4d94ab Updates FB sync script 2012-11-10 22:55:44 +01:00
Jonathan Lillesæter 305e9888a0 whoops 2012-05-20 20:54:36 +02:00
Jonathan Lillesæter e0936bb64e Added product sales updating in realtime 2012-05-20 20:08:06 +02:00
nikolark 9854a17f4e Stats: hourly 2012-04-15 17:50:13 +02:00
nikolark 3229d54365 Functional product stats 2012-04-15 14:44:12 +02:00
nikolark 598e2a8e57 Time for bed 2012-04-15 03:03:21 +02:00
nikolark d112e1b195 Less broke 2012-04-15 01:38:57 +02:00
nikolark da554ff9da Started adding stats (broken) 2012-04-15 01:28:15 +02:00
nikolark 3b2389094d Minor 2012-03-26 04:19:52 +02:00
nikolark f1d7516341 Adds basic order status 2012-03-26 04:18:27 +02:00
nikolark edf1426ed2 Responsive design fixes 2012-03-26 02:46:25 +02:00
nikolark cfb1074277 Fixes form submit 2012-03-21 13:12:29 +01:00
nikolark a93e9178dc Fixes icons 2012-03-20 00:05:23 +01:00
nikolark 07f37e57df Log: Adds show all 2012-03-19 23:50:53 +01:00
nikolark 14f4a9ec3d Adds transactions and some tooltips++ 2012-03-19 22:20:32 +01:00
nikolark 0cbda8e0c5 Log: Highlights product amount 2012-03-19 20:46:11 +01:00
nikolark b9db82e31f Adds order log (no delete yet) and more. 2012-03-19 20:40:31 +01:00
nikolark 99a8a3aeb4 Updates bootstrap to v2.0.1.2 and fixes empty orders 2012-03-19 18:55:13 +01:00
nikolark c82a76d4ce UI++ 2012-03-18 02:57:47 +01:00
nikolark 11a18b6aba Adds FB picture sync script and UI fixes 2012-03-18 02:29:49 +01:00
nikolark 9f1911289a Register: Only display active products 2012-03-18 00:03:55 +01:00
nikolark ce5b36ec37 Small fixes 2012-02-25 12:35:48 +01:00
nikolark 8cdddefacc Sorts users after last purchase 2012-02-23 04:21:50 +01:00
nikolark 85071ba6af Adds gravatars and firstnames 2012-02-23 03:28:52 +01:00
nikolark c05e8f9aa4 Forgot some files 2012-02-23 02:53:49 +01:00
nikolark e7d499aeca Adds messages and balance check 2012-02-23 02:53:09 +01:00
nikolark 54ec001fb1 Adds purchase feedback 2012-02-22 21:48:25 +01:00
nikolark fc400dffd4 Adds working deposit and purchases.
- Switch to bootstrap.
 - Still no balance checking or form feedback messages.
2012-02-22 05:22:04 +01:00
Paal Braathen ce8190c870 Ldap setting fixes 2012-01-24 17:14:41 +01:00
Paal Braathen 8dd1fd6573 Removed ldap from DATABASES 2012-01-24 17:10:40 +01:00
Paal Braathen 2dd7bb6224 Added LDAP settings 2012-01-24 16:54:53 +01:00
nikolark c634d16ee1 Deploy fixes 2012-01-07 18:58:01 +01:00
157 changed files with 27366 additions and 1355 deletions

No files matched your search

+12 -4
View File
@@ -1,6 +1,14 @@
*.swp
*.pyc
settings.py
dev.db
static/gaute/.sass-cache
uploads
local_settings.py
db.sqlite3
static/.sass-cache
uploads/[^.]*
venv/
node_modules/
.idea/
CACHE/
media/
tmp/
frontend/dist
static/modern
+35
View File
@@ -0,0 +1,35 @@
variables:
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
default:
image: nikolaik/python-nodejs:python3.9-nodejs16
cache:
paths:
- .cache/pip
- .venv
before_script:
- poetry config virtualenvs.in-project true
- poetry install
django-checks:
stage: build
script:
- poetry run python manage.py migrate
- poetry run python manage.py check
tests:
stage: test
script:
- poetry run bin/test
lint:
stage: test
script:
- poetry run bin/lint
deploy:
stage: deploy
script:
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
only:
- main
+18
View File
@@ -0,0 +1,18 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: local
hooks:
- id: black
name: black
entry: black
args: [--check]
language: system
require_serial: true
types: [python]
- id: isort
name: isort
entry: isort
args: [--check]
language: system
types: [python]
-5
View File
@@ -1,5 +0,0 @@
TODO:
- Prototype register GUI.
- Show picture of buyers and username, with balance > 0.
- Submit orders
- User profile settings
+36
View File
@@ -0,0 +1,36 @@
# Getting started
Before starting, make sure you have a working python and nodejs setup with pyenv and nvm.
```bash
apt-get install libmysqlclient-dev
poetry install
poetry shell
pre-commit install
python manage.py migrate
bin/run
# in another terminal
cd frontend
npm install
npm start
```
## References
- We use JS code generation to work with queries/mutations in our graphql API: https://github.com/dotansimha/graphql-typed-document-node
## TODO
- update emails
- Stats: products over time
- Light on dark colors
- inventory views
## TODO-old
- Larger pictures
- Compact GUI for max 768 vertical pixels
- Search right of in title
-3
View File
@@ -1,3 +0,0 @@
from django.db import models
# Create your models here.
-16
View File
@@ -1,16 +0,0 @@
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application.
"""
from django.test import TestCase
class SimpleTest(TestCase):
def test_basic_addition(self):
"""
Tests that 1 + 1 always equals 2.
"""
self.assertEqual(1 + 1, 2)
-1
View File
@@ -1 +0,0 @@
# Create your views here.
View File
File renamed without changes.
+146
View File
@@ -0,0 +1,146 @@
# Django settings for bbs project.
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = True
ADMINS = (
# ('Your Name', 'your_email@example.com'),
)
MANAGERS = ADMINS
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "db.sqlite3",
},
}
TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "nb"
USE_I18N = True
USE_L10N = True
SITE_ID = 1
# Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
# Static files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/"
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
# Make this unique, and don't share it with anybody.
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.request",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
]
},
},
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
ROOT_URLCONF = "bbssite.urls"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
"django_extensions",
"easy_thumbnails",
"corsheaders",
]
LOCAL_APPS = [
"main",
]
INSTALLED_APPS += LOCAL_APPS
LOGIN_REDIRECT_URL = "/"
SESSION_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
BBS_SALDO_MAX = 600
BBS_LIMIT_DEPOSITS = False
# Thumbnails
THUMBNAIL_ALIASES = {
"": {
"product": {"size": (64, 64), "crop": True},
},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"root": {
"level": "INFO",
"handlers": ["console"],
},
"formatters": {
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"loggers": {
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CORS_ALLOWED_ORIGINS = [
"https://bbs.neuf.no",
"http://localhost:8000",
"http://localhost:3000",
]
try:
from .local_settings import *
except ImportError:
pass
+22
View File
@@ -0,0 +1,22 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import include, path, re_path
from strawberry.django.views import GraphQLView
from main import urls as main_urls
from main.api.schema import schema
admin.autodiscover()
urlpatterns = [
re_path(r"", include(main_urls)),
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path("accounts/logout/", LogoutView.as_view(), name="django.contrib.auth.views.logout"),
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
]
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbssite.settings")
application = get_wsgi_application()
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -eu
PROJECT_PATH=/var/www/neuf.no/bbs
PROCESS_NAME="$PROJECT.neuf.no"
cd "$PROJECT_PATH" || exit 1
git pull
poetry install
umask 022; poetry run python manage.py collectstatic --noinput # Collect static
poetry run python manage.py migrate # Run DB migrations
# Reload
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
SRC_DIR="main bbssite"
black $SRC_DIR
isort $SRC_DIR
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
python manage.py export_schema
cd frontend && npm run generate
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
SRC_DIR="main bbssite"
black --check $SRC_DIR
isort --diff $SRC_DIR
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
python manage.py runserver
+1
View File
@@ -0,0 +1 @@
cd frontend && npm run start
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
pytest -v
-15
View File
@@ -1,15 +0,0 @@
import os
import sys
from os.path import dirname, join
def map_path(target_name=''):
'''Enables path names to be decided at runtime.'''
return join(dirname(__file__), target_name).replace('\\', '/')
os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
path = map_path(".")
if path not in sys.path:
sys.path.append(path)
import django.core.handlers.wsgi
application = django.core.handlers.wsgi.WSGIHandler()
+19
View File
@@ -0,0 +1,19 @@
version: '3'
services:
mysql:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: dev
MYSQL_DATABASE: bbs
ports:
- "3306:3306"
volumes:
- mydata:/var/lib/mysql
networks:
default:
driver: bridge
volumes:
mydata:
+1
View File
@@ -0,0 +1 @@
src/types.ts
+53
View File
@@ -0,0 +1,53 @@
module.exports = {
env: {
browser: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
},
extends: [
"plugin:prettier/recommended",
"plugin:react/recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-console": "off",
"prefer-destructuring": "off",
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: ["**/*.test.js", "**/*.spec.js", "**/__tests__/**"],
},
],
"import/prefer-default-export": "off",
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", ["parent", "sibling", "index"]],
"newlines-between": "always",
alphabetize: { order: "asc" },
},
],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
"react/button-has-type": "error",
"react/jsx-filename-extension": ["warn", { extensions: [".js", ".jsx", ".ts", ".tsx"] }],
"react/jsx-uses-react": "off",
"react/prefer-stateless-function": "warn",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/sort-comp": "off",
},
plugins: ["prettier", "react", "react-hooks"],
settings: {
react: {
version: "detect",
},
},
};
+12
View File
@@ -0,0 +1,12 @@
{
"name": "bbs-schema",
"schemaPath": "../schema.graphql",
"extensions": {
"endpoints": {
"default": {
"url": "http://localhost:8000/graphql/",
"introspect": false
}
}
}
}
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
v16
+3
View File
@@ -0,0 +1,3 @@
{
"printWidth": 120
}
+8
View File
@@ -0,0 +1,8 @@
schema: ../schema.graphql
documents: ./src/**/*.graphql
generates:
./src/types.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22024
View File
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
{
"name": "bbs",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc && vite build",
"check-types": "tsc",
"dev": "vite",
"format:js": "eslint --fix src",
"format:styles": "stylelint --fix src",
"generate": "graphql-codegen",
"lint:js": "eslint src",
"lint:styles": "stylelint src",
"preview": "vite preview",
"start": "vite",
"test": "jest"
},
"dependencies": {
"@apollo/client": "^3.5.8",
"@nivo/bar": "^0.79.1",
"@nivo/line": "^0.79.1",
"@nivo/pie": "^0.79.1",
"bootstrap": "^5.1.3",
"bootstrap-icons": "^1.8.1",
"clsx": "^1.1.1",
"date-fns": "^2.28.0",
"graphql": "^16.3.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.27.1",
"react-router-dom": "^6.2.1",
"use-url-search-params": "^2.5.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.6.1",
"@graphql-codegen/typed-document-node": "^2.2.3",
"@graphql-codegen/typescript-operations": "^2.3.0",
"@graphql-typed-document-node/core": "^3.1.1",
"@types/bootstrap": "^5.1.9",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.11.0",
"@typescript-eslint/parser": "^5.11.0",
"@vitejs/plugin-react": "^1.2.0",
"@vitejs/plugin-react-refresh": "^1.3.6",
"eslint": "^8.9.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"jest": "^27.5.1",
"prettier": "^2.5.1",
"stylelint": "^14.5.0",
"stylelint-config-idiomatic-order": "^8.1.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^7.0.0",
"stylelint-order": "^5.0.0",
"stylelint-prettier": "^2.0.0",
"typescript": "^4.5.5",
"vite": "^2.8.1",
"vite-plugin-svgr": "^1.0.1"
},
"engines": {
"node": ">=16",
"npm": ">=7"
}
}
+3
View File
@@ -0,0 +1,3 @@
.app {
margin: 16px;
}
+27
View File
@@ -0,0 +1,27 @@
import { ApolloProvider } from "@apollo/client";
import React from "react";
import { BrowserRouter as Router } from "react-router-dom";
import "bootstrap/dist/css/bootstrap.min.css";
import styles from "./App.module.css";
import { Routes } from "./Routes";
import client from "./client";
import { Header } from "./components/Header";
import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider";
const App: React.FC = () => (
<ApolloProvider client={client}>
<Router>
<ToastProvider>
<Header />
<div className={styles.app}>
<Routes />
</div>
<Toast />
</ToastProvider>
</Router>
</ApolloProvider>
);
export default App;
+26
View File
@@ -0,0 +1,26 @@
import React from "react";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
import { UserCreate } from "./features/auth/UserCreate";
import { Deposit } from "./features/deposit/Deposit";
import { Log } from "./features/log/Log";
import { Register } from "./features/register/Register";
import { OrderStats } from "./features/stats/OrderStats";
import { ProductStats } from "./features/stats/ProductStats";
import { Stats } from "./features/stats/Stats";
import { UserDetail } from "./features/users/UserDetail";
import { UserList } from "./features/users/UserList";
export const Routes: React.FC = () => (
<ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} />
<Route path="/log" element={<Log />} />
<Route path="/stats" element={<Stats />} />
<Route path="/stats/orders" element={<OrderStats />} />
<Route path="/stats/products" element={<ProductStats />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} />
</ReactRouterRoutes>
);
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="g5" transform="matrix(1.301558, 0, 0, 1.301558, -16.927717, 11.280891)" style="">
<g id="g7">
<g id="g9">
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect11" style="fill:#fed600"/>
</g>
<defs id="defs13">
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood16"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend18"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089" height="27.089" id="SVGID_1_">
<g id="g21" style="filter:url(#Adobe_OpacityMaskFilter)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,10.3325)" id="image23" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 FX+uRPy5Prky5c/8H//Z"/>
</g>
</mask>
<g mask="url(#SVGID_1_)" id="g25" style="opacity:0.46000001">
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect27" style="fill:#ffffff"/>
</g>
</g>
<g id="g29">
<g id="g31">
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect33" style="fill:#fed600"/>
</g>
<defs id="defs35">
<filter id="Adobe_OpacityMaskFilter_1_" filterUnits="userSpaceOnUse" x="48.799" y="41.158001" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood38"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend40"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="48.799" y="41.158" width="27.089" height="27.089" id="SVGID_2_">
<g id="g43" style="filter:url(#Adobe_OpacityMaskFilter_1_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,39.1582)" id="image45" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 uztZ9R//2Q=="/>
</g>
</mask>
<g mask="url(#SVGID_2_)" id="g47" style="opacity:0.46000001">
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect49" style="fill:#ffffff"/>
</g>
</g>
<g id="g51">
<g id="g53">
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect55" style="fill:#fed600"/>
</g>
<defs id="defs57">
<filter id="Adobe_OpacityMaskFilter_2_" filterUnits="userSpaceOnUse" x="78.223" y="41.158001" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood60"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend62"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="78.223" y="41.158" width="27.089" height="27.089" id="SVGID_3_">
<g id="g65" style="filter:url(#Adobe_OpacityMaskFilter_2_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,76.2231,39.1582)" id="image67" style="overflow:visible" xlink:href="data:image/jpeg;base64,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"/>
</g>
</mask>
<g mask="url(#SVGID_3_)" id="g69" style="opacity:0.46000001">
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect71" style="fill:#ffffff"/>
</g>
</g>
<g id="g73">
<g id="g75">
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect77" style="fill:#fed600"/>
</g>
<defs id="defs79">
<filter id="Adobe_OpacityMaskFilter_3_" filterUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089001">
<feFlood result="back" id="feFlood82"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend84"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089" id="SVGID_4_">
<g id="g87" style="filter:url(#Adobe_OpacityMaskFilter_3_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,17.6567,10.3325)" id="image89" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 Z9L/AErWzlbXa7CGrtNXV1fznz5/wf/Z"/>
</g>
</mask>
<g mask="url(#SVGID_4_)" id="g91" style="opacity:0.46000001">
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect93" style="fill:#ffffff"/>
</g>
</g>
</g>
<rect x="18.633" y="79.66" fill="none" width="615.332" height="68.885" id="rect95"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+8
View File
@@ -0,0 +1,8 @@
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: import.meta.env.VITE_API_URL || "http://localhost:8000/graphql/",
cache: new InMemoryCache(),
});
export default client;
+13
View File
@@ -0,0 +1,13 @@
.logo {
position: relative;
top: -1px;
display: inline-block;
width: 24px;
height: 24px;
margin: 0 8px;
}
.navIcon {
position: relative;
top: -2px;
}
+90
View File
@@ -0,0 +1,90 @@
import { ReactComponent as BarChart } from "bootstrap-icons/icons/bar-chart.svg";
import { ReactComponent as CardList } from "bootstrap-icons/icons/card-list.svg";
import { ReactComponent as House } from "bootstrap-icons/icons/house.svg";
import { ReactComponent as PeopleIcon } from "bootstrap-icons/icons/people.svg";
import React from "react";
import { NavLink } from "react-router-dom";
import { ReactComponent as Logo } from "../assets/brick.svg";
import styles from "./Header.module.css";
const ADMIN_URL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/admin`
: "http://localhost:8000/admin/";
const navLinkStyles = ({ isActive }: { isActive: boolean }) => (isActive ? "nav-link active" : "nav-link");
export const Header: React.FC = () => {
return (
<header>
<nav className="navbar navbar-dark bg-dark navbar-expand-md">
<NavLink to="/" className="navbar-brand">
<span className={styles.logo}>
<Logo />
</span>
Biceps Bar System - BBS
</NavLink>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav">
<li className="nav-item">
<NavLink to="/" className={navLinkStyles} end>
<span className={styles.navIcon}>
<House />
</span>{" "}
Kjøp drikke
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/log" className={navLinkStyles}>
<span className={styles.navIcon}>
<CardList />
</span>{" "}
Logg
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/stats" className={navLinkStyles}>
<span className={styles.navIcon}>
<BarChart />
</span>{" "}
Statistikk
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/users" className={navLinkStyles}>
<span className={styles.navIcon}>
<PeopleIcon />
</span>{" "}
Folk
</NavLink>
</li>
</ul>
</div>
<ul className="navbar-nav">
<li className="nav-item">
<a href={ADMIN_URL} className="nav-link">
Admin
</a>
</li>
</ul>
</nav>
</header>
);
};
@@ -0,0 +1,57 @@
.product {
display: flex;
overflow: hidden;
flex-direction: column;
align-items: center;
justify-content: center;
border: 4px solid transparent;
background: transparent;
border-radius: 8px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
}
.product:hover {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(0, 0, 0, 0.1);
}
.active {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(25, 135, 84, 0.2);
}
.active:hover {
background: rgba(25, 135, 84, 0.2);
}
.productText {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 4px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
color: white;
font-weight: bold;
}
.productImage {
width: 140px;
height: 140px;
object-fit: contain;
}
.noProductImage {
width: 140px;
height: 140px;
}
.amount {
position: absolute;
top: 0px;
left: 0px;
padding: 4px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
color: white;
font-size: 2rem;
font-weight: bold;
line-height: 1;
}
.wrap {
position: relative;
}
+26
View File
@@ -0,0 +1,26 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { Product } from "../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
onProduct: MouseEventHandler;
active: boolean;
amount: number;
};
export const ProductCard: React.FC<ProductCardProps> = ({ product, onProduct, amount, active = false }) => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct}>
<div className={styles.wrap}>
<span className={styles.amount}>{amount}</span>
{product.imageUrl && <img src={product.imageUrl} className={styles.productImage} alt={product.name} />}
{!product.imageUrl && <div className={styles.noProductImage} />}
<span className={styles.productText}>{product.name}</span>
</div>
</button>
);
};
+5
View File
@@ -0,0 +1,5 @@
.toast {
position: absolute;
bottom: 32px;
left: 32px;
}
+41
View File
@@ -0,0 +1,41 @@
import { Toast as BSToast } from "bootstrap";
import clsx from "clsx";
import React, { useEffect, useRef } from "react";
import styles from "./Toast.module.css";
import { useToast } from "./ToastProvider";
export const Toast: React.FC = () => {
const ref = useRef<HTMLInputElement>(null);
const { visible, showToast, message, background } = useToast();
const backgroundColor = `bg-${background}`;
const classes = clsx("toast align-items-center text-white bg-primary border-0", backgroundColor, styles.toast);
useEffect(() => {
if (!ref.current) return;
const bsToast = BSToast.getInstance(ref.current) || new BSToast(ref.current);
if (visible) {
bsToast?.show();
} else if (!visible) {
bsToast?.hide();
}
return () => bsToast?.hide();
}, [visible]);
return (
<div ref={ref} className={classes} role="alert" aria-live="assertive" aria-atomic="true">
<div className="d-flex">
<div className="toast-body">{message}</div>
<button
type="button"
className="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast"
aria-label="Close"
onClick={() => showToast(message)}
/>
</div>
</div>
);
};
+51
View File
@@ -0,0 +1,51 @@
import React, { ReactNode, useContext, useState } from "react";
interface ToastProps {
visible: boolean;
message: string;
showToast: (message: string, background?: string) => void;
hide: () => void;
background: string;
}
const defaultState = {
visible: false,
message: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
hide: () => {},
background: "primary",
};
const ToastContext = React.createContext<ToastProps>(defaultState);
type ToastProviderProps = {
children: ReactNode;
};
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [visible, setVisible] = useState(defaultState.visible);
const [message, setMessage] = useState("");
const [background, setBackground] = useState("");
const showToast = (newMessage: string, background = "primary") => {
setMessage(newMessage);
setBackground(background);
if (!visible) {
setVisible(true);
setTimeout(() => {
setVisible(false);
setMessage("");
}, 3000);
}
};
const hide = () => {
setVisible(true);
setMessage("");
};
return (
<ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider>
);
};
export const useToast = (): ToastProps => useContext(ToastContext);
@@ -0,0 +1,34 @@
.user {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px;
border: 3px solid transparent;
background: transparent;
border-radius: 8px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
}
.user:hover {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(0, 0, 0, 0.1);
}
.active {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(25, 135, 84, 0.2);
}
.active:hover {
background: rgba(25, 135, 84, 0.2);
}
.avatar {
width: 50px;
height: 50px;
border-radius: 50px;
}
.avatar svg {
width: 50px;
height: 50px;
color: #ccc;
}
.name {
font-weight: bold;
}
+31
View File
@@ -0,0 +1,31 @@
import { ReactComponent as PersonFill } from "bootstrap-icons/icons/person-fill.svg";
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../types";
import styles from "./UserCard.module.css";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
onUser: MouseEventHandler;
active: boolean;
};
/** If user is null, then it's the special external user */
export const UserCard: React.FC<UserCardProps> = ({ user, onUser, active = false }) => {
const classes = clsx(styles.user, { [styles.active]: active });
return (
<button className={classes} onClick={onUser} type="button">
{user?.profile?.image && (
<img src={user.profile?.image} alt={`Profile for ${user.firstName}`} className={styles.avatar} />
)}
{!user?.profile?.image && (
<span className={styles.avatar}>
<PersonFill />
</span>
)}
<span className={styles.name}>{user?.firstName || "Ekstern"}</span>
<span className={styles.balance}>{user?.profile?.balance || ""}</span>
</button>
);
};
+49
View File
@@ -0,0 +1,49 @@
import { useMutation } from "@apollo/client";
import React from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { CreateUserDocument } from "../../types";
type FormValues = {
firstName: string;
lastName: string;
email: string;
};
export const UserCreate = () => {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>();
const [createUser, { loading: creating }] = useMutation(CreateUserDocument);
const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
await createUser({ variables: data });
} catch (err) {
console.error("woops", err);
return;
}
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>Fornavn</label>
<input {...register("firstName", { required: true })} />
{errors.firstName && <span>This field is required</span>}
<br />
<label>Etternavn</label>
<input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>}
<br />
<label>E-post</label>
<input type="email" {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
<br />
<input type="submit" disabled={creating} />
</form>
);
};
@@ -0,0 +1,10 @@
mutation CreateUser($firstName: String!, $lastName: String!, $email: String!) {
createUser(input: {firstName: $firstName, lastName: $lastName, email: $email}) {
id
firstName
lastName
profile {
id
}
}
}
@@ -0,0 +1,13 @@
.userList {
display: grid;
height: 400px;
grid-template-columns: repeat(12, 100px);
overflow-y: auto;
row-gap: 16px;
}
.amountSection {
margin-top: 32px;
}
.checkout {
margin-top: 32px;
}
+67
View File
@@ -0,0 +1,67 @@
import React from "react";
import { UserCard } from "../../components/UserCard";
import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit";
export const Deposit: React.FC = () => {
const {
loading,
selectedUser,
setSelectedUser,
amount,
setAmount,
userQuery,
setUserQuery,
filteredUsers,
reset,
onSubmit,
} = useDeposit();
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<section>
<h2>Hvem skal sette inn penger?</h2>
<input
name="userQuery"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
value={userQuery}
placeholder="Filtrér på navn"
/>
{userQuery !== "" && (
<button className="btn btn-link" type="button" onClick={() => setUserQuery("")}>
Vis alle
</button>
)}
<div className={styles.userList}>
{filteredUsers.map((user) => (
<UserCard
key={user.id}
active={selectedUser === user.id}
onUser={() => {
setSelectedUser(user.id);
}}
user={user}
/>
))}
</div>
</section>
<section className={styles.amountSection}>
<h2>Hvor mye?</h2>
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" />
</section>
<section className={styles.checkout}>
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}>
Sett inn
</button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
@@ -0,0 +1,33 @@
mutation CreateDeposit($userId: ID!, $amount: Int!) {
createDeposit(userId: $userId, amount:$amount) {
... on CreateDepositSuccess {
transaction {
id
amount
user {
id
firstName
lastName
profile {
id
balance
}
}
}
}
... on AboveMaxSaldo {
error
message
maxSaldo
above
}
... on FormErrors {
error
message
fields {
field
message
}
}
}
}
@@ -0,0 +1,67 @@
import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import { useToast } from "../../components/ToastProvider";
import { AllUsersDocument, CreateDepositDocument } from "../../types";
export const useDeposit = () => {
const { data, loading } = useQuery(AllUsersDocument);
const [createDeposit] = useMutation(CreateDepositDocument);
const [selectedUser, setSelectedUser] = useState("");
const [amount, setAmount] = useState("");
const [userQuery, setUserQuery] = useState("");
const filteredUsers = (data?.allUsers || []).filter((user) =>
`${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase())
);
const { showToast } = useToast();
const onSubmit = async () => {
const amountInt = parseInt(amount, 10) || 0;
const variables = { userId: selectedUser, amount: amountInt };
let res;
try {
res = await createDeposit({ variables });
} catch (e) {
console.log("woops", e);
showToast("Kunne ikke sette inn penger", "danger");
return;
}
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
const { transaction } = res.data?.createDeposit;
showToast(
`${transaction.user.firstName} har satt inn${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`
);
reset();
return;
}
if (res.data?.createDeposit?.__typename === "FormErrors") {
showToast(res.data?.createDeposit.message, "danger");
return;
}
if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") {
showToast(
`${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`,
"danger"
);
}
};
const reset = () => {
setSelectedUser("");
setAmount("");
setUserQuery("");
};
return {
loading,
selectedUser,
setSelectedUser,
amount,
setAmount,
userQuery,
setUserQuery,
filteredUsers,
reset,
onSubmit,
};
};
+5
View File
@@ -0,0 +1,5 @@
.log {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
+145
View File
@@ -0,0 +1,145 @@
import { useQuery } from "@apollo/client";
import clsx from "clsx";
import React from "react";
import { useUrlSearchParams } from "use-url-search-params";
import { OrderListDocument, TransactionListDocument } from "../../types";
import { formatTime } from "../../utils/time";
import styles from "./Log.module.css";
const PAGE_SIZE = 10;
export const Log = () => {
const types = {
orderOffset: Number,
transactionOffset: Number,
};
const [params, setParams] = useUrlSearchParams({}, types);
const orderOffset = Number(params.orderOffset) || 0;
const transactionOffset = Number(params.transactionOffset) || 0;
const { data: ordersData, loading: ordersLoading } = useQuery(OrderListDocument, {
variables: { offset: orderOffset },
});
const { data: transactionsData, loading: transactionsLoading } = useQuery(TransactionListDocument, {
variables: { offset: transactionOffset },
});
const loading = ordersLoading || transactionsLoading;
if (loading) return <div>Loading...</div>;
const orders = ordersData?.orderList || [];
const transactions = transactionsData?.transactionList || [];
return (
<div className={styles.log}>
<div>
<h2>Ordre</h2>
<table className="table">
<thead>
<tr>
<th scope="col">Når</th>
<th scope="col">Hvem</th>
<th scope="col">Hva</th>
<th scope="col">Sum</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>{formatTime(order.created)}</td>
<td>{order.isExternal ? "Ekstern" : `${order.customer?.firstName} ${order.customer?.lastName}`}</td>
<td>
{(order.orderlines ?? []).map((line) => (
<div key={line.id}>
{line.amount}x {line.product.name}
</div>
))}
</td>
<td>{order.orderSum}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Order pagination">
<ul className="pagination">
<li className={clsx("page-item", { disabled: orderOffset <= 0 })}>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: orderOffset - PAGE_SIZE });
}}
>
Previous
</a>
</li>
<li className="page-item">
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: orderOffset + PAGE_SIZE });
}}
>
Next
</a>
</li>
</ul>
</nav>
</div>
<div>
<h2>Transaksjoner</h2>
<table className="table">
<thead>
<tr>
<th scope="col">Når</th>
<th scope="col">Hvem</th>
<th scope="col">Innskudd</th>
</tr>
</thead>
<tbody>
{transactions.map((trans) => (
<tr key={trans.id}>
<td>{formatTime(trans.created)}</td>
<td>
{trans.user.firstName} {trans.user.lastName}
</td>
<td>{trans.amount}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Transaction pagination">
<ul className="pagination">
<li className={clsx("page-item", { disabled: transactionOffset <= 0 })}>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: transactionOffset - PAGE_SIZE });
}}
>
Previous
</a>
</li>
<li className="page-item">
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: transactionOffset + PAGE_SIZE });
}}
>
Next
</a>
</li>
</ul>
</nav>
</div>
</div>
);
};
@@ -0,0 +1,28 @@
query orderList($limit: Int = 10, $offset: Int = 0) {
orderList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
id
isExternal
created
orderSum
customer {
id
firstName
lastName
profile {
id
balance
}
}
orderlines {
id
amount
unitPrice
product {
id
name
salePriceExt
salePriceInt
}
}
}
}
@@ -0,0 +1,16 @@
query transactionList($limit: Int = 10, $offset: Int = 0) {
transactionList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
id
created
amount
user {
id
firstName
lastName
profile {
id
balance
}
}
}
}
@@ -0,0 +1,28 @@
.userList {
display: grid;
grid-template-columns: repeat(12, 128px);
row-gap: 16px;
}
.productList {
display: grid;
grid-template-columns: repeat(8, 150px);
row-gap: 16px;
}
.productListSection {
margin-top: 2rem;
}
.checkout {
margin-top: 32px;
}
.total {
display: inline-block;
min-width: 200px;
padding: 4px 8px;
margin-right: 8px;
background: black;
color: white;
font-size: 1.8rem;
}
@@ -0,0 +1,82 @@
import React from "react";
import { ProductCard } from "../../components/ProductCard";
import { UserCard } from "../../components/UserCard";
import { OrderLineInput, Product } from "../../types";
import styles from "./Register.module.css";
import { useRegister } from "./useRegister";
const USER_EXTERNAL = "external";
export const Register: React.FC = () => {
const {
users,
products,
loading,
showAll,
setShowAll,
setSelectedUser,
selectedUser,
addToOrder,
order,
total,
onSubmit,
reset,
} = useRegister();
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<section>
<h2>Hvem kjøper?</h2>
<div className={styles.userList}>
<UserCard
key={USER_EXTERNAL}
user={null}
onUser={() => setSelectedUser(USER_EXTERNAL)}
active={selectedUser == USER_EXTERNAL}
/>
{users.map((user) => (
<UserCard
key={user.id}
user={user}
onUser={() => setSelectedUser(user.id)}
active={selectedUser == user.id}
/>
))}
</div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}>
{showAll ? "Skjul gamliser" : "Vis alle"}
</button>
</section>
<section className={styles.productListSection}>
<h2>Kjøper hva?</h2>
<div className={styles.productList}>
{products.map((product: Omit<Product, "userCounts">) => (
<ProductCard
key={product.id}
product={product}
onProduct={() => addToOrder(product.id)}
amount={order.find((orderLine: OrderLineInput) => orderLine.productId === product.id)?.amount || 0}
active={order.some((orderLine: OrderLineInput) => orderLine.productId === product.id)}
/>
))}
</div>
</section>
<section className={styles.checkout}>
<span className={styles.total}>
Totalt: <span>{total}</span>
</span>
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}>
Kjøp
</button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
@@ -0,0 +1,9 @@
query allProducts($active: Boolean!) {
allProducts(filters: {active: $active}) {
id
name
imageUrl
salePriceExt
salePriceInt
}
}
@@ -0,0 +1,13 @@
query allUsers {
allUsers {
id
firstName
lastName
profile {
id
balance
image
lastPurchaseDate
}
}
}
@@ -0,0 +1,44 @@
mutation CreateOrder($customerId: ID!, $orderLines:[OrderLineInput!]!, $isExternal: Boolean!) {
createOrder(customerId: $customerId, orderLines: $orderLines, isExternal: $isExternal) {
... on CreateOrderSuccess {
order {
id
isExternal
customer {
id
firstName
lastName
profile {
id
balance
}
}
orderlines {
id
amount
unitPrice
product {
id
name
salePriceExt
salePriceInt
}
}
orderSum
}
}
... on InsufficientFunds {
error
message
amountLacking
}
... on FormErrors {
error
message
fields {
field
message
}
}
}
}
@@ -0,0 +1,103 @@
import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import { useToast } from "../../components/ToastProvider";
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types";
import { activeUserFilter } from "../../utils/users";
const USER_EXTERNAL = "external";
const sumTotal = (products: Omit<Product, "userCounts">[], order: OrderLineInput[], isExternal: boolean) => {
const prices = order.map((orderLine: OrderLineInput) => {
const product = products.find((product: Omit<Product, "userCounts">) => product.id === orderLine.productId);
if (!product) return 0;
return orderLine.amount * (isExternal ? product.salePriceExt : product.salePriceInt);
});
return prices.reduce((partialSum, a) => partialSum + a, 0);
};
export const useRegister = () => {
const [showAll, setShowAll] = useState(false);
const [selectedUser, setSelectedUser] = useState("");
const [order, setOrder] = useState<OrderLineInput[]>([]);
const { showToast } = useToast();
const { data, loading } = useQuery(AllProductsDocument, { variables: { active: true } });
const products = data?.allProducts || [];
const { data: usersData, loading: usersLoading } = useQuery(AllUsersDocument);
const allUsers = usersData?.allUsers || [];
const users = showAll ? allUsers : allUsers.filter(activeUserFilter);
const [createOrderMutation] = useMutation(CreateOrderDocument, {
variables: { customerId: selectedUser, isExternal: selectedUser === USER_EXTERNAL, orderLines: order },
});
const reset = () => {
setOrder([]);
setSelectedUser("");
};
const addToOrder = (productId: string) => {
let added = false;
const newOrder = order.map((orderLine: OrderLineInput) => {
if (orderLine.productId === productId) {
added = true;
return { ...orderLine, amount: orderLine.amount + 1 };
}
return orderLine;
});
if (!added) {
newOrder.push({ productId, amount: 1 });
}
setOrder(newOrder);
};
const total = sumTotal(products, order, selectedUser === USER_EXTERNAL);
const onSubmit = async () => {
let res;
try {
res = await createOrderMutation();
} catch (e) {
showToast("Whoops");
console.log("woops");
return;
}
const { data } = res;
const createOrderResponse = data?.createOrder;
if (createOrderResponse?.__typename === "CreateOrderSuccess") {
const { order } = createOrderResponse;
const orderLines = (order?.orderlines || [])
.map((orderLine) => `${orderLine.amount} ${orderLine.product.name}`)
.join(", ");
const who = !order.customer ? "Ekstern" : `${order.customer?.firstName} ${order.customer?.lastName}`;
showToast(`${who} kjøpte: ${orderLines}`);
reset();
return;
}
if (createOrderResponse?.__typename === "FormErrors") {
showToast(createOrderResponse.message, "danger");
return;
}
if (createOrderResponse?.__typename === "InsufficientFunds") {
showToast(`${createOrderResponse.message} Du mangler ${createOrderResponse.amountLacking},-`, "danger");
}
};
return {
loading: loading || usersLoading,
users,
products,
showAll,
setShowAll,
setSelectedUser,
selectedUser,
addToOrder,
order,
total,
onSubmit,
reset,
};
};
@@ -0,0 +1,79 @@
import { useQuery } from "@apollo/client";
import { Bar, BarDatum } from "@nivo/bar";
import React from "react";
import { OrderStatsDocument, OrderStatsByTime } from "../../types";
import { StatsView } from "./StatsView";
const days = { 1: "Mandag", 2: "Tirsdag", 3: "Onsdag", 4: "Torsdag", 5: "Fredag", 6: "Lørdag", 7: "Søndag" };
const months = {
1: "Januar",
2: "Februar",
3: "Mars",
4: "April",
5: "Mai",
6: "Juni",
7: "Juli",
8: "August",
9: "September",
10: "Oktober",
11: "November",
12: "Desember",
};
const OrderBar = ({ data }: { data: BarDatum[] }) => (
<Bar
data={data}
margin={{ left: 60, bottom: 40 }}
width={1200}
height={400}
colorBy="indexValue"
enableLabel={false}
tooltipLabel={(d) => d.id.toString()}
axisLeft={{ legend: "Ordre", legendPosition: "middle", legendOffset: -50 }}
/>
);
export const OrderStats = () => {
const { data, loading } = useQuery(OrderStatsDocument);
return (
<StatsView loading={loading}>
<h3>Ordre per time</h3>
<OrderBar
data={(data?.orderStats?.hourly || []).map((stat: OrderStatsByTime) => ({
id: `${stat.period.toString().padStart(2, "0")}:00`,
label: stat.period,
value: stat.count,
}))}
/>
<h3>Ordre per ukedag</h3>
<OrderBar
data={(data?.orderStats?.daily || []).map((stat: OrderStatsByTime) => ({
/* @ts-expect-error Here, stat.period can only be a key in days range. */
id: days[stat.period],
label: stat.period,
value: stat.count,
}))}
/>
<h3>Ordre per måned</h3>
<OrderBar
data={(data?.orderStats?.monthly || []).map((stat: OrderStatsByTime) => ({
/* @ts-expect-error Here, stat.period can only be a key in months range. */
id: months[stat.period],
label: stat.period,
value: stat.count,
}))}
/>
<h3>Ordre per år</h3>
<OrderBar
data={(data?.orderStats?.yearly || []).map((stat: OrderStatsByTime) => ({
id: stat.period,
label: stat.period,
value: stat.count,
}))}
/>
</StatsView>
);
};
@@ -0,0 +1,40 @@
import { useQuery } from "@apollo/client";
import { Pie } from "@nivo/pie";
import React from "react";
import { AllProductStatsDocument } from "../../types";
import styles from "./Stats.module.css";
import { StatsView } from "./StatsView";
type UserCount = {
name: string;
count: number;
};
export const ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true },
});
return (
<StatsView loading={loading}>
<div className={styles.products}>
{(data?.allProducts || []).map((product) => (
<div key={product.id}>
{product.name}
<Pie
margin={{ top: 40, right: 120, bottom: 40, left: 120 }}
data={product.userCounts.map((uc: UserCount) => ({
id: uc.name,
label: uc.name,
value: uc.count,
}))}
width={400}
height={300}
/>
</div>
))}
</div>
</StatsView>
);
};
@@ -0,0 +1,9 @@
.products {
display: grid;
max-width: 1200px;
gap: 1rem;
grid-template-columns: repeat(3, 1fr);
}
.statsNav {
margin-bottom: 1rem;
}
+70
View File
@@ -0,0 +1,70 @@
import { useQuery } from "@apollo/client";
import { Line } from "@nivo/line";
import React from "react";
import { ProductStatsDocument } from "../../types";
import { StatsView } from "./StatsView";
export const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument);
const series: any[] = [];
(data?.productStats || [])?.forEach(({ productName: id, data }) => {
const lineData: any[] = [];
data.forEach((point, index) => {
lineData.push({ x: point.x, y: point.y + (lineData?.[index - 1]?.y || 0) });
});
series.push({ id, data: lineData });
});
return (
<StatsView loading={loading}>
<h2>Kjøp over tid</h2>
<Line
data={series}
width={1000}
height={400}
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
stacked: false,
}}
axisLeft={{
legend: "Purchases",
legendOffset: 12,
}}
axisBottom={{
format: "%b %Y",
legend: "Date",
legendOffset: -40,
}}
useMesh
pointLabelYOffset={0}
tooltip={({ point }) => (
<div
style={{
background: "white",
padding: "9px 12px",
border: "1px solid #ccc",
}}
>
<div>
<strong>{point.data.xFormatted}</strong>
</div>
<div>
<span style={{ background: point.serieColor, width: 13, height: 13, display: "inline-block" }} />{" "}
<strong>{point.data.yFormatted}</strong> {point.serieId}
</div>
</div>
)}
/>
</StatsView>
);
};
+41
View File
@@ -0,0 +1,41 @@
import React from "react";
import { NavLink } from "react-router-dom";
import styles from "./Stats.module.css";
const navLinkStyles = ({ isActive }: { isActive: boolean }) => (isActive ? "nav-link active" : "nav-link");
export const StatsNav = () => (
<div className={styles.statsNav}>
<ul className="nav nav-tabs">
<li className="nav-item">
<NavLink className={navLinkStyles} to="/stats" end>
Oversikt
</NavLink>
</li>
<li className="nav-item">
<NavLink className={navLinkStyles} to="/stats/products">
Produkter
</NavLink>
</li>
<li className="nav-item">
<NavLink className={navLinkStyles} to="/stats/orders">
Ordre
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link disabled" to="/stats/users" title="TODO">
Folk
</NavLink>
</li>
</ul>
</div>
);
export const StatsView = ({ children, loading }: { children: React.ReactNode; loading: boolean }) => (
<div className={styles.stats}>
<h2>Statistikk</h2>
<StatsNav />
{loading && <div>Loading...</div>}
{!loading && children}
</div>
);
@@ -0,0 +1,7 @@
query allProductStats($active: Boolean) {
allProducts(filters: {active: $active}) {
id
name
userCounts
}
}
@@ -0,0 +1,20 @@
query orderStats {
orderStats {
yearly {
period
count
}
monthly {
period
count
}
daily {
period
count
}
hourly {
period
count
}
}
}
@@ -0,0 +1,9 @@
query productStats {
productStats {
productName
data {
x
y
}
}
}
@@ -0,0 +1,5 @@
.userDetail {
display: grid;
gap: 1rem;
grid-template-columns: 400px 1fr;
}
+110
View File
@@ -0,0 +1,110 @@
import { useQuery } from "@apollo/client";
import { Line } from "@nivo/line";
import React from "react";
import { useParams } from "react-router-dom";
import { User, UserDetailDocument, UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time";
import styles from "./UserDetail.module.css";
export const UserDetail = () => {
const { userId } = useParams();
const { data, loading } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
if (loading) return <div>Loading...</div>;
const user = data?.user as User;
const profile = user?.profile as UserProfile;
// FIXME: types
const series: any[] = [];
(profile?.productOrderStats || [])?.forEach(({ productName: id, data }) => {
const lineData: any[] = [];
data.forEach((point, index) => {
lineData.push({ x: point.x, y: point.y + (lineData?.[index - 1]?.y || 0) });
});
series.push({ id, data: lineData });
});
return (
<div className={styles.userDetail}>
<div>
<h1>
{profile.image && <img src={profile.image} alt={`${user.firstName}s profile picture`} />} {user.firstName}
</h1>
<div className="card">
<div className="card-header">Stats</div>
<ul className="list-group list-group-flush">
<li className="list-group-item">
Saldo: <strong>{profile.balance.toLocaleString("no")} kr</strong>
</li>
<li className="list-group-item">
Siste kjøp: <strong>{formatTime(profile.lastPurchaseDate)}</strong>
</li>
<li className="list-group-item">
Kjøp totalt: <strong>{profile.orderSumTotal.toLocaleString("no")} kr</strong>
</li>
</ul>
</div>
<br />
<div className="card">
<div className="card-header">Top 5 måneder</div>
<ul className="list-group list-group-flush">
{profile.topMonths.map((topMonth) => (
<li key={topMonth.period} className="list-group-item">
{formatMonth(topMonth.period)}: <strong>{topMonth.count}</strong> kjøp på totalt{" "}
<strong>{topMonth.sum} kr</strong>
</li>
))}
</ul>
</div>
</div>
<div>
<h2>Kjøp over tid</h2>
<Line
data={series}
width={1000}
height={400}
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
stacked: false,
}}
axisLeft={{
legend: "Purchases",
legendOffset: 12,
}}
axisBottom={{
format: "%b %Y",
legend: "Date",
legendOffset: -12,
}}
useMesh
pointLabelYOffset={0}
tooltip={({ point }) => (
<div
style={{
background: "white",
padding: "9px 12px",
border: "1px solid #ccc",
}}
>
<div>
<strong>{point.data.xFormatted}</strong>
</div>
<div>
<span style={{ background: point.serieColor, width: 13, height: 13, display: "inline-block" }} />{" "}
<strong>{point.data.yFormatted}</strong> {point.serieId}
</div>
</div>
)}
/>
</div>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import { useQuery } from "@apollo/client";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { AllUsersDocument } from "../../types";
import { activeUserFilter } from "../../utils/users";
export const UserList = () => {
const { data, loading } = useQuery(AllUsersDocument);
const [showAll, setShowAll] = useState(false);
if (loading) return <div>Loading...</div>;
const allUsers = data?.allUsers || [];
const users = showAll ? allUsers : allUsers.filter((u) => activeUserFilter(u));
return (
<div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}>
{showAll ? "Skjul gamliser" : "Vis alle"}
</button>
<ul>
{(users || []).map((user) => (
<li key={user.id}>
<Link to={`/user/${user.id}`}>
{user.firstName} {user.lastName}
</Link>
</li>
))}
</ul>
<br />
<Link to="/users/create" className="btn btn-primary">
Opprett ny
</Link>
</div>
);
};
@@ -0,0 +1,26 @@
query userDetail($userId: ID!) {
user(userId: $userId) {
id
firstName
lastName
profile {
id
balance
image
lastPurchaseDate
topMonths {
period
sum
count
}
productOrderStats {
productName
data {
x
y
}
}
orderSumTotal
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { useCallback, useEffect, useRef } from "react";
export function usePrevious(value: any) {
// The ref object is a generic container whose current property is mutable ...
// ... and can hold any value, similar to an instance property on a class
const ref = useRef();
// Store current value in ref
useEffect(() => {
ref.current = value;
}, [value]); // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current;
}
export function useToast() {
const show = useCallback(() => {
console.log("showing toast with element from root");
}, []);
return { show };
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
+339
View File
@@ -0,0 +1,339 @@
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
/** Date with time (isoformat) */
DateTime: any;
/** The GenericScalar scalar type represents a generic GraphQL scalar value that could be: List or Object. */
JSONScalar: any;
};
export type AboveMaxSaldo = Error & {
__typename?: 'AboveMaxSaldo';
above: Scalars['Float'];
error: Scalars['Boolean'];
maxSaldo: Scalars['Float'];
message: Scalars['String'];
};
export type CreateDepositResponse = AboveMaxSaldo | CreateDepositSuccess | FormErrors;
export type CreateDepositSuccess = {
__typename?: 'CreateDepositSuccess';
transaction: Transaction;
};
export type CreateOrderResponse = CreateOrderSuccess | FormErrors | InsufficientFunds;
export type CreateOrderSuccess = {
__typename?: 'CreateOrderSuccess';
order: Order;
};
export type Error = {
error: Scalars['Boolean'];
message: Scalars['String'];
};
export type FieldError = Error & {
__typename?: 'FieldError';
error: Scalars['Boolean'];
field: Scalars['String'];
message: Scalars['String'];
};
export type FormErrors = {
__typename?: 'FormErrors';
error: Scalars['Boolean'];
fields: Array<FieldError>;
message: Scalars['String'];
};
export type InsufficientFunds = Error & {
__typename?: 'InsufficientFunds';
amountLacking: Scalars['Float'];
error: Scalars['Boolean'];
message: Scalars['String'];
};
export type Mutation = {
__typename?: 'Mutation';
createDeposit: CreateDepositResponse;
createOrder: CreateOrderResponse;
createUser: User;
};
export type MutationCreateDepositArgs = {
amount: Scalars['Int'];
userId: Scalars['ID'];
};
export type MutationCreateOrderArgs = {
customerId: Scalars['ID'];
isExternal?: Scalars['Boolean'];
orderLines: Array<OrderLineInput>;
};
export type MutationCreateUserArgs = {
input: UserInput;
};
export type OffsetPaginationInput = {
limit?: Scalars['Int'];
offset?: Scalars['Int'];
};
export type Order = {
__typename?: 'Order';
created: Scalars['DateTime'];
customer?: Maybe<User>;
id: Scalars['ID'];
isExternal: Scalars['Boolean'];
orderSum: Scalars['Float'];
orderlines?: Maybe<Array<OrderLine>>;
};
export type OrderLine = {
__typename?: 'OrderLine';
amount: Scalars['Int'];
id: Scalars['ID'];
order: Order;
product: Product;
unitPrice: Scalars['Float'];
};
export type OrderLineInput = {
amount: Scalars['Int'];
productId: Scalars['ID'];
};
export type OrderOrdering = {
created?: InputMaybe<Ordering>;
};
export type OrderStats = {
__typename?: 'OrderStats';
daily: Array<OrderStatsByTime>;
hourly: Array<OrderStatsByTime>;
monthly: Array<OrderStatsByTime>;
yearly: Array<OrderStatsByTime>;
};
export type OrderStatsByTime = {
__typename?: 'OrderStatsByTime';
count: Scalars['String'];
period: Scalars['Int'];
};
export enum Ordering {
Asc = 'ASC',
Desc = 'DESC'
}
export type Point = {
__typename?: 'Point';
x: Scalars['String'];
y: Scalars['Int'];
};
export type Product = {
__typename?: 'Product';
id: Scalars['ID'];
imageUrl: Scalars['String'];
name: Scalars['String'];
salePriceExt: Scalars['Float'];
salePriceInt: Scalars['Float'];
userCounts: Scalars['JSONScalar'];
};
export type ProductFilter = {
active?: InputMaybe<Scalars['Boolean']>;
id?: InputMaybe<Scalars['ID']>;
};
export type ProductOrderStat = {
__typename?: 'ProductOrderStat';
data: Array<Point>;
productName: Scalars['String'];
};
export type Query = {
__typename?: 'Query';
allOrders: Array<Order>;
allProducts: Array<Product>;
allUsers: Array<User>;
orderList: Array<Order>;
orderStats: OrderStats;
productStats: Array<ProductOrderStat>;
transactionList: Array<Transaction>;
user: User;
};
export type QueryAllProductsArgs = {
filters?: InputMaybe<ProductFilter>;
};
export type QueryOrderListArgs = {
order?: InputMaybe<OrderOrdering>;
pagination?: InputMaybe<OffsetPaginationInput>;
};
export type QueryTransactionListArgs = {
order?: InputMaybe<TransactionOrdering>;
pagination?: InputMaybe<OffsetPaginationInput>;
};
export type QueryUserArgs = {
userId: Scalars['ID'];
};
export type TopMonth = {
__typename?: 'TopMonth';
count: Scalars['Int'];
period: Scalars['String'];
sum: Scalars['Int'];
};
export type Transaction = {
__typename?: 'Transaction';
amount: Scalars['Float'];
created: Scalars['DateTime'];
id: Scalars['ID'];
user: User;
};
export type TransactionOrdering = {
created?: InputMaybe<Ordering>;
};
export type User = {
__typename?: 'User';
firstName: Scalars['String'];
id: Scalars['ID'];
lastName: Scalars['String'];
profile?: Maybe<UserProfile>;
};
export type UserInput = {
email?: InputMaybe<Scalars['String']>;
firstName?: InputMaybe<Scalars['String']>;
lastName?: InputMaybe<Scalars['String']>;
};
export type UserProfile = {
__typename?: 'UserProfile';
balance: Scalars['Float'];
id: Scalars['ID'];
image: Scalars['String'];
lastPurchaseDate: Scalars['DateTime'];
orderSumTotal: Scalars['Float'];
productOrderStats: Array<ProductOrderStat>;
topMonths: Array<TopMonth>;
};
export type CreateUserMutationVariables = Exact<{
firstName: Scalars['String'];
lastName: Scalars['String'];
email: Scalars['String'];
}>;
export type CreateUserMutation = { __typename?: 'Mutation', createUser: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string } | null } };
export type CreateDepositMutationVariables = Exact<{
userId: Scalars['ID'];
amount: Scalars['Int'];
}>;
export type CreateDepositMutation = { __typename?: 'Mutation', createDeposit: { __typename?: 'AboveMaxSaldo', error: boolean, message: string, maxSaldo: number, above: number } | { __typename?: 'CreateDepositSuccess', transaction: { __typename?: 'Transaction', id: string, amount: number, user: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number } | null } } } | { __typename?: 'FormErrors', error: boolean, message: string, fields: Array<{ __typename?: 'FieldError', field: string, message: string }> } };
export type OrderListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
}>;
export type OrderListQuery = { __typename?: 'Query', orderList: Array<{ __typename?: 'Order', id: string, isExternal: boolean, created: any, orderSum: number, customer?: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number } | null } | null, orderlines?: Array<{ __typename?: 'OrderLine', id: string, amount: number, unitPrice: number, product: { __typename?: 'Product', id: string, name: string, salePriceExt: number, salePriceInt: number } }> | null }> };
export type TransactionListQueryVariables = Exact<{
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
}>;
export type TransactionListQuery = { __typename?: 'Query', transactionList: Array<{ __typename?: 'Transaction', id: string, created: any, amount: number, user: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number } | null } }> };
export type AllProductsQueryVariables = Exact<{
active: Scalars['Boolean'];
}>;
export type AllProductsQuery = { __typename?: 'Query', allProducts: Array<{ __typename?: 'Product', id: string, name: string, imageUrl: string, salePriceExt: number, salePriceInt: number }> };
export type AllUsersQueryVariables = Exact<{ [key: string]: never; }>;
export type AllUsersQuery = { __typename?: 'Query', allUsers: Array<{ __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number, image: string, lastPurchaseDate: any } | null }> };
export type CreateOrderMutationVariables = Exact<{
customerId: Scalars['ID'];
orderLines: Array<OrderLineInput> | OrderLineInput;
isExternal: Scalars['Boolean'];
}>;
export type CreateOrderMutation = { __typename?: 'Mutation', createOrder: { __typename?: 'CreateOrderSuccess', order: { __typename?: 'Order', id: string, isExternal: boolean, orderSum: number, customer?: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number } | null } | null, orderlines?: Array<{ __typename?: 'OrderLine', id: string, amount: number, unitPrice: number, product: { __typename?: 'Product', id: string, name: string, salePriceExt: number, salePriceInt: number } }> | null } } | { __typename?: 'FormErrors', error: boolean, message: string, fields: Array<{ __typename?: 'FieldError', field: string, message: string }> } | { __typename?: 'InsufficientFunds', error: boolean, message: string, amountLacking: number } };
export type AllProductStatsQueryVariables = Exact<{
active?: InputMaybe<Scalars['Boolean']>;
}>;
export type AllProductStatsQuery = { __typename?: 'Query', allProducts: Array<{ __typename?: 'Product', id: string, name: string, userCounts: any }> };
export type OrderStatsQueryVariables = Exact<{ [key: string]: never; }>;
export type OrderStatsQuery = { __typename?: 'Query', orderStats: { __typename?: 'OrderStats', yearly: Array<{ __typename?: 'OrderStatsByTime', period: number, count: string }>, monthly: Array<{ __typename?: 'OrderStatsByTime', period: number, count: string }>, daily: Array<{ __typename?: 'OrderStatsByTime', period: number, count: string }>, hourly: Array<{ __typename?: 'OrderStatsByTime', period: number, count: string }> } };
export type ProductStatsQueryVariables = Exact<{ [key: string]: never; }>;
export type ProductStatsQuery = { __typename?: 'Query', productStats: Array<{ __typename?: 'ProductOrderStat', productName: string, data: Array<{ __typename?: 'Point', x: string, y: number }> }> };
export type UserDetailQueryVariables = Exact<{
userId: Scalars['ID'];
}>;
export type UserDetailQuery = { __typename?: 'Query', user: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number, image: string, lastPurchaseDate: any, orderSumTotal: number, topMonths: Array<{ __typename?: 'TopMonth', period: string, sum: number, count: number }>, productOrderStats: Array<{ __typename?: 'ProductOrderStat', productName: string, data: Array<{ __typename?: 'Point', x: string, y: number }> }> } | null } };
export const CreateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"firstName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lastName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"firstName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"firstName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"lastName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lastName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode<CreateUserMutation, CreateUserMutationVariables>;
export const CreateDepositDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDeposit"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"amount"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDeposit"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"amount"},"value":{"kind":"Variable","name":{"kind":"Name","value":"amount"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CreateDepositSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transaction"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AboveMaxSaldo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"maxSaldo"}},{"kind":"Field","name":{"kind":"Name","value":"above"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FormErrors"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateDepositMutation, CreateDepositMutationVariables>;
export const OrderListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"orderList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orderList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"created"},"value":{"kind":"EnumValue","value":"DESC"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isExternal"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"orderSum"}},{"kind":"Field","name":{"kind":"Name","value":"customer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderlines"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"unitPrice"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceExt"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceInt"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OrderListQuery, OrderListQueryVariables>;
export const TransactionListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"transactionList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"10"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transactionList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"created"},"value":{"kind":"EnumValue","value":"DESC"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}}]}}]}}]} as unknown as DocumentNode<TransactionListQuery, TransactionListQueryVariables>;
export const AllProductsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"allProducts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"active"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allProducts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"Variable","name":{"kind":"Name","value":"active"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceExt"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceInt"}}]}}]}}]} as unknown as DocumentNode<AllProductsQuery, AllProductsQueryVariables>;
export const AllUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"allUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"lastPurchaseDate"}}]}}]}}]}}]} as unknown as DocumentNode<AllUsersQuery, AllUsersQueryVariables>;
export const CreateOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"customerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderLines"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderLineInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isExternal"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"customerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"customerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderLines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderLines"}}},{"kind":"Argument","name":{"kind":"Name","value":"isExternal"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isExternal"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CreateOrderSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isExternal"}},{"kind":"Field","name":{"kind":"Name","value":"customer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderlines"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"unitPrice"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceExt"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceInt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderSum"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InsufficientFunds"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"amountLacking"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FormErrors"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateOrderMutation, CreateOrderMutationVariables>;
export const AllProductStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"allProductStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"active"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allProducts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"Variable","name":{"kind":"Name","value":"active"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"userCounts"}}]}}]}}]} as unknown as DocumentNode<AllProductStatsQuery, AllProductStatsQueryVariables>;
export const OrderStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"orderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"daily"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"hourly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]}}]} as unknown as DocumentNode<OrderStatsQuery, OrderStatsQueryVariables>;
export const ProductStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"productStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode<ProductStatsQuery, ProductStatsQueryVariables>;
export const UserDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"userDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"lastPurchaseDate"}},{"kind":"Field","name":{"kind":"Name","value":"topMonths"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"sum"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"productOrderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderSumTotal"}}]}}]}}]}}]} as unknown as DocumentNode<UserDetailQuery, UserDetailQueryVariables>;
+15
View File
@@ -0,0 +1,15 @@
/* svgs */
declare module "*.svg" {
import { ReactElement, SVGProps } from "react";
export const ReactComponent: (props: SVGProps<SVGElement>) => ReactElement;
}
/* CSS modules */
declare module "*.module.css";
/* Vite env vars */
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+5
View File
@@ -0,0 +1,5 @@
import { format, parseISO } from "date-fns";
export const formatTime = (isoDateTime: string): string => format(parseISO(isoDateTime), "yyyy-MM-dd HH:mm");
export const formatMonth = (isoDateTime: string): string => format(parseISO(isoDateTime), "yyyy-MM");
+13
View File
@@ -0,0 +1,13 @@
import { parseISO, subDays } from "date-fns";
import { AllUsersQuery } from "../types";
const activeLimitDays = 365 / 2;
type FilterableUser = AllUsersQuery["allUsers"][0];
export const activeUserFilter = (user: FilterableUser) => {
const balance = user.profile?.balance || 0;
const active = parseISO(user.profile?.lastPurchaseDate) >= subDays(new Date(), activeLimitDays);
return balance >= 15 && active;
};
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
extends: ["stylelint-config-recommended", "stylelint-prettier/recommended", "stylelint-config-idiomatic-order"],
plugins: ["stylelint-order"],
rules: {
// Useful?
"no-descending-specificity": null,
},
ignoreFiles: ["**/*.md", "**/*.svg", "**/*.jpg", "**/*.graphql", "**/*.tsx", "**/*.ts"],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2021"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ES2020",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
},
"include": [
"./src",
"node_modules/vite/types/importMeta.d.ts"
]
}
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies */
import { defineConfig } from "vite";
import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.NODE_ENV !== "production" ? "/" : "/static/modern/",
plugins: [svgr()],
define: {
__DEV__: process.env.NODE_ENV !== "production",
},
envDir: "..",
build: { outDir: "../static/modern", emptyOutDir: true },
});
+5 -1
View File
@@ -1,10 +1,14 @@
from django.contrib import admin
from bbs.main.models import *
from main.models import *
class ProductAdmin(admin.ModelAdmin):
pass
admin.site.register(Product, ProductAdmin)
admin.site.register(InventoryTransaction)
admin.site.register(Order)
admin.site.register(OrderLine)
admin.site.register(Transaction)
File renamed without changes.
+22
View File
@@ -0,0 +1,22 @@
import strawberry
@strawberry.interface
class Error:
message: str
@strawberry.field()
def error(self) -> bool:
return True
@strawberry.type
class FieldError(Error):
field: str
@strawberry.type
class FormErrors:
fields: list[FieldError]
message: str = ""
error: bool = True
View File
Whitespace-only changes.
+137
View File
@@ -0,0 +1,137 @@
import strawberry
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.shortcuts import get_object_or_404
from strawberry import ID
from main import models
from main.api.errors import Error, FieldError, FormErrors
from main.api.types.inventory import Transaction
from main.api.types.shop import Order
UserModel = get_user_model()
@strawberry.input
class OrderLineInput:
product_id: ID
amount: int
@strawberry.type
class CreateOrderSuccess:
order: Order
@strawberry.type
class InsufficientFunds(Error):
amount_lacking: float
CreateOrderResponse = strawberry.union("CreateOrderResponse", [CreateOrderSuccess, InsufficientFunds, FormErrors])
@strawberry.mutation
@transaction.atomic
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse:
# clean and validate
try:
user = UserModel.objects.get(pk=customer_id)
except (UserModel.DoesNotExist, ValueError):
user = None
if user is None and not is_external:
message = "Du har ikke valgt hvem som skal kjøpe."
return FormErrors(
message=message,
fields=[FieldError(field="customer_id", message=message)],
)
if not order_lines:
message = "Du har ikke valgt hva du skal kjøpe."
return FormErrors(
message=message,
fields=[FieldError(field="order_lines", message=message)],
)
ols = []
order_sum = 0
for ol in order_lines:
product = get_object_or_404(models.Product, pk=ol.product_id)
price = product.sale_price_ext if is_external else product.sale_price_int
ols.append(models.OrderLine(product=product, amount=ol.amount, unit_price=price))
order_sum += ol.amount * price
product.inventory_amount -= ol.amount
product.save()
if not is_external and user.profile.balance <= order_sum:
return InsufficientFunds(
message="Du har ikke nok penger på bok.", amount_lacking=abs(order_sum - user.profile.balance)
)
# create
order = models.Order.objects.create(customer=user, order_sum=order_sum)
for ol in ols:
ol.order = order
models.OrderLine.objects.bulk_create(ols)
# update balance
if not is_external:
user.profile.balance -= order_sum
user.profile.save()
return CreateOrderSuccess(order=order)
@strawberry.type
class CreateDepositSuccess:
transaction: Transaction
@strawberry.type
class AboveMaxSaldo(Error):
max_saldo: float
above: float
CreateDepositResponse = strawberry.union("CreateDepositResponse", [CreateDepositSuccess, AboveMaxSaldo, FormErrors])
@strawberry.mutation
@transaction.atomic
def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
# clean and validate
try:
user = UserModel.objects.get(pk=user_id)
except (UserModel.DoesNotExist, ValueError):
message = "Du har ikke valgt hvem som sette inn penger."
return FormErrors(
message=message,
fields=[FieldError(field="user_id", message=message)],
)
if amount <= 0:
message = "Du har ikke valgt beløp som skal settes inn."
return FormErrors(
message=message,
fields=[FieldError(field="amount", message=message)],
)
# 1337 is special <3
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337:
return AboveMaxSaldo(
message="Du prøver å sette inn for mye penger",
max_saldo=settings.BBS_SALDO_MAX,
above=amount + user.profile.balance - settings.BBS_SALDO_MAX,
)
trans = models.Transaction.objects.create(user=user, amount=amount)
profile = trans.user.profile
profile.balance += trans.amount
profile.save()
return CreateDepositSuccess(transaction=trans)
@strawberry.type
class ShopMutations:
create_order = create_order
create_deposit = create_deposit
+32
View File
@@ -0,0 +1,32 @@
import strawberry
import strawberry_django
from django.contrib.auth import get_user_model
from django.db import transaction
from strawberry_django import auto
from main import models
from main.api.types.users import User
DjangoUser = get_user_model()
@strawberry_django.input(DjangoUser)
class UserInput:
email: auto
first_name: auto
last_name: auto
@strawberry.mutation
@transaction.atomic
def register_mutation(input: UserInput) -> User:
user = DjangoUser.objects.create_user(
first_name=input.first_name, last_name=input.last_name, email=input.email, username=input.email
)
models.UserProfile.objects.create(user=user)
return user
@strawberry.type
class UsersMutations:
create_user = register_mutation
View File
Whitespace-only changes.
+9
View File
@@ -0,0 +1,9 @@
import strawberry
import strawberry_django
from main.api.types.inventory import Transaction, TransactionOrdering
@strawberry.type
class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, order=TransactionOrdering)
+31
View File
@@ -0,0 +1,31 @@
import strawberry
import strawberry_django
from main import models
from main.api.types.shop import Order, OrderOrdering, OrderStats, OrderStatsByTime, Point, Product, ProductOrderStat
@strawberry.type
class ShopQueries:
all_products: list[Product] = strawberry_django.field()
all_orders: list[Order] = strawberry_django.field()
order_list: list[Order] = strawberry_django.field(pagination=True, order=OrderOrdering)
@strawberry.field
def order_stats(self) -> OrderStats:
return OrderStats(
**{
period: [
OrderStatsByTime(**stat)
for stat in models.Order.objects.count_by_created_period(period).order_by("period")
]
for period in ["yearly", "monthly", "daily", "hourly"]
}
)
@strawberry.field
def product_stats(self) -> list[ProductOrderStat]:
return [
ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
for stat in models.OrderLine.objects.product_order_stats().as_series()
]
+15
View File
@@ -0,0 +1,15 @@
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from strawberry import ID
from main.api.types.users import User
@strawberry.type
class UsersQueries:
all_users: list[User] = strawberry_django.field()
@strawberry.field
def user(self, user_id: ID) -> User:
return DjangoUser.objects.get(pk=user_id)
+20
View File
@@ -0,0 +1,20 @@
import strawberry
from main.api.mutations.shop import ShopMutations
from main.api.mutations.users import UsersMutations
from main.api.queries.inventory import InventoryQueries
from main.api.queries.shop import ShopQueries
from main.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
schema = strawberry.Schema(query=Query, mutation=Mutation)
View File
Whitespace-only changes.
+18
View File
@@ -0,0 +1,18 @@
import strawberry_django
from strawberry_django import auto
from main import models
from main.api.types.users import User
@strawberry_django.ordering.order(models.Transaction)
class TransactionOrdering:
created: auto
@strawberry_django.type(models.Transaction)
class Transaction:
id: auto
user: "User"
amount: auto
created: auto
+73
View File
@@ -0,0 +1,73 @@
import strawberry
import strawberry_django
from strawberry_django import auto
from main import models
from main.api.types.utils import JSONScalar
@strawberry_django.filters.filter(models.Product)
class ProductFilter:
id: auto
active: auto
@strawberry_django.type(models.Product, filters=ProductFilter)
class Product:
id: auto
name: auto
image_url: str
sale_price_int: auto
sale_price_ext: auto
# FIXME: stricter types
user_counts: JSONScalar
@strawberry_django.ordering.order(models.Order)
class OrderOrdering:
created: auto
@strawberry_django.type(models.Order)
class Order:
id: auto
customer: strawberry.LazyType["User", "main.api.types.users"]
order_sum: auto
created: auto
orderlines: list["OrderLine"]
is_external: bool
@strawberry_django.type(models.OrderLine)
class OrderLine:
id: auto
order: Order
product: Product
amount: auto
unit_price: auto
@strawberry.type
class OrderStatsByTime:
period: int
count: str
@strawberry.type
class OrderStats:
yearly: list[OrderStatsByTime]
monthly: list[OrderStatsByTime]
daily: list[OrderStatsByTime]
hourly: list[OrderStatsByTime]
@strawberry.type
class Point:
x: str
y: int
@strawberry.type
class ProductOrderStat:
product_name: str
data: list[Point]
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
import strawberry
import strawberry_django
from django.contrib.auth import get_user_model
from strawberry_django import auto
from main import models
from main.api.types.shop import Point, ProductOrderStat
UserModel = get_user_model()
@strawberry.type
class TopMonth:
period: str
count: int
sum: int
@strawberry_django.type(models.UserProfile)
class UserProfile:
id: auto
balance: auto
image: auto
last_purchase_date: datetime
order_sum_total: float
@strawberry.field()
def top_months(self) -> list[TopMonth]:
return [TopMonth(**month) for month in self.top_months()]
@strawberry.field()
def product_order_stats(self) -> list[ProductOrderStat]:
return [
ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
for stat in self.product_order_stats().as_series()
]
@strawberry_django.type(UserModel)
class User:
id: auto
first_name: auto
last_name: auto
profile: UserProfile
+11
View File
@@ -0,0 +1,11 @@
import json
from typing import Any, NewType
import strawberry
JSONScalar = strawberry.scalar(
NewType("JSONScalar", Any),
serialize=lambda v: v,
parse_value=lambda v: json.loads(v),
description="The GenericScalar scalar type represents a generic GraphQL scalar value that could be: List or Object.",
)
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class BBSAppConfig(AppConfig):
name = "main"
verbose_name = "Biceps Bar System"
def ready(self):
# all models are loaded, could attach signals
pass
+16 -13
View File
@@ -1,18 +1,21 @@
from django import forms
from models import *
from main.models import InventoryTransaction, Product
class OrderForm(forms.Form):
pass
class OrderLineForm(forms.ModelForm):
#product = forms.CharField(required=True, widget=forms.HiddenInput)
#amount = forms.IntegerField(required=True)
class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.FloatField(required=True, localize=True)
def __init__(self, *args, **kwargs):
# Only show active products
super(InventoryTransactionForm, self).__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(active=True)
class Meta:
model = OrderLine
fields = ('amount','product')
widgets = {
'product': forms.HiddenInput(),
'amount': forms.TextInput(attrs={'size': '2'}),
}
# TODO : Validation
model = InventoryTransaction
fields = ["product", "amount", "unit_price", "comment"]
class DateRangeForm(forms.Form):
start_time = forms.DateTimeField(required=False, label="Fra")
end_time = forms.DateTimeField(required=False, label="Til")
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more