Author SHA1 Message Date
alve ff392fd066 🏃Add gitea actions demo
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 45s
2025-07-03 20:53:04 +02:00
ponas 4a36cc140c Sort imports 2025-06-05 19:53:56 +02:00
ponas f1ea46188f Make the graphql endpoint CSRF exempt 2025-06-05 19:38:17 +02:00
ponas dd6a559ac6 Add mise.toml 2025-06-05 19:37:59 +02:00
nikolark 5d889ee32c chore(ci): Fix deploy
Ensure uv is on path
2025-05-31 23:13:54 +02:00
nikolark 2b1cdb28ea fix: Fix union syntax 2025-05-30 16:17:45 +02:00
nikolark fbd55bd3a2 fix: strawberry type things 2025-05-30 16:15:18 +02:00
nikolark 71f158da6e chore: Fix most ruff issues
ignore the rest using `uvx ruff check --add-noqa`
2025-05-30 15:43:57 +02:00
nikolark 8b364b0887 chore: Add pre-commit ruff config++ 2025-05-30 15:43:20 +02:00
nikolark 28d747d74d chore(frontend): Roll node to v20 2025-05-30 15:32:55 +02:00
nikolark 6f964a0c57 chore: Roll to python 3.9 to get uv in image 2025-05-30 15:29:41 +02:00
nikolark d256e0eb7e chore: Switch to uv 2025-05-30 15:26:51 +02:00
leander cac189003a increase max saldo to 1000 NOK 2025-01-16 18:25:33 +01:00
alve ff09985944 🥷 Hide users navigation tab 2024-02-15 19:29:13 +00:00
Emma Eilefsen Glenna d76c911d4b fix rendering of register total 2024-01-25 21:45:17 +01:00
Emma Eilefsen Glenna e60473e7ed fix rendering of user balance 2024-01-25 21:44:03 +01:00
Emma Eilefsen Glenna d596c31353 extract function 2024-01-25 21:43:32 +01:00
Emma Eilefsen Glenna 90558a3a28 add comment 2024-01-25 19:01:45 +00:00
alve 57a6c412d5 💄 Hide trailing decimal zeros 2024-01-25 19:01:45 +00:00
alve 19f58211e7 🗃 Make order sum a decimal field 2024-01-25 19:01:45 +00:00
nikolark 0726754678 💡 add some FIXMEs 2024-01-25 19:01:45 +00:00
nikolark 480fba870a 🗃 Replace float fields with int or decimals 2024-01-25 19:01:45 +00:00
alve ed08c60380 💄Fix favicon path 2023-07-19 19:46:01 +02:00
nikolark 79fe5cac0f 🔒 Audit 2022-04-17 21:29:02 +02:00
nikolark 95d7d13f38 ✨ Handle user not found 2022-04-17 21:13:22 +02:00
nikolark ce2cdfd409 ⚡ Code split routes 2022-04-17 16:59:00 +02:00
nikolark b56d103ae7 Merge branch 'feature/synthwave' into 'main'
✨ BBS 2222 synthwave theme

See merge request edb/bbs!3
2022-04-17 14:39:44 +00:00
nikolark 56efe6760e 💄 Fix deposit 2022-04-17 16:37:42 +02:00
nikolark ce31ef84cc 💄 Pretty deposit user select 2022-04-17 16:29:18 +02:00
nikolark 491604c720 🚚 Move ProductCard and UserCard
closer to their feature
2022-04-17 16:04:54 +02:00
nikolark 05d3dcbb3e 💄 Style product grid, rm images
♻ Move Wireframe to own component
2022-04-17 16:01:09 +02:00
nikolark 8577fb9f11 ⬆ Bump minor frontend deps
♻ Stop using React.FC
2022-04-17 15:00:10 +02:00
nikolark a038340e4a 📝 TODO 2022-03-27 21:42:27 +02:00
nikolark 34a01e9293 💄 move grid behind everything else 2022-03-27 21:41:30 +02:00
nikolark 54dd21f1eb 💄 Make you nauseous 2022-03-22 23:59:31 +01:00
nikolark 53ba5a05a1 ✨ Add credits
🐛 Fix navigating below page zero
2022-03-06 21:16:53 +01:00
nikolark f775446224 💄 Style Navlink, primary buttons and headings 2022-03-06 15:16:16 +01:00
nikolark 13cc0d92a2 💄 Put new brand in header and fix toasts 2022-03-06 14:27:52 +01:00
nikolark 7bfa587a77 💄 Avatar rhombus 2022-03-06 13:34:05 +01:00
nikolark 3d91148b7e 💄 First round of styling
✨ Added new splash
2022-03-06 13:13:08 +01:00
nikolark 54bbd75730 🔥 Remove all things bootstrap 2022-03-05 20:00:03 +01:00
nikolark 06f43b06b8 ⚡ Avoid redirect 2022-02-20 12:36:15 +01:00
nikolark ad116c2c28 💚 Update pytest config with new app name 2022-02-20 01:33:16 +01:00
nikolark eb960bdb84 🔧 Update scripts with new path 2022-02-20 01:32:36 +01:00
nikolark f4c99153ca 🔧 Point VITE_API_URL to the right place 2022-02-20 01:31:03 +01:00
nikolark 3e457de416 🐛 Fix VITE_API_URL reference 2022-02-20 01:25:55 +01:00
nikolark faca98b7f9 🔧 Reference static assests from / 2022-02-20 01:20:41 +01:00
nikolark 53ef262ba4 🔧 Build frontend on deploy 2022-02-20 01:16:13 +01:00
nikolark f4749e6a6e 🔧 output frontend build to root static folder 2022-02-20 01:12:37 +01:00
nikolark e5de16f227 🐛 Remove 3.9 type syntax 2022-02-20 00:57:12 +01:00
nikolark eace79f012 ⬇ Downgrade to python 3.8 2022-02-20 00:54:00 +01:00
nikolark 9b903f42d6 🚚 Rename app main to bbs
🚚 Move settings into main
➕ Add django-rename-app
2022-02-20 00:39:16 +01:00
nikolark e1eaf3df98 💚 Try fixing deploy script 2022-02-20 00:17:51 +01:00
nikolark b79d9fc9c8 👷 Add ssh config 2022-02-20 00:08:15 +01:00
nikolark 42f23b1d77 Merge branch 'chore/bbs-bumps' into 'master'
💥 Rewrite in react

See merge request edb/bbs!1
2022-02-19 22:39:07 +00:00
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
196 changed files with 27613 additions and 12267 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000
+19
View File
@@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
+14 -3
View File
@@ -1,6 +1,17 @@
*.swp
*.pyc
settings.py
dev.db
static/gaute/.sass-cache
local_settings.py
db.sqlite3
static/.sass-cache
uploads/[^.]*
venv/
node_modules/
.idea/
CACHE/
media/
tmp/
frontend/dist
static/assets
static/index.html
.python-version
.env
+43
View File
@@ -0,0 +1,43 @@
variables:
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
default:
image: nikolaik/python-nodejs:python3.9-nodejs20
cache:
paths:
- .cache/pip
- .venv
django-checks:
stage: build
script:
- uv run manage.py migrate
- uv run manage.py check
tests:
stage: test
script:
- uv run bin/test
lint:
stage: test
script:
- uv run bin/pre-commit
deploy:
stage: deploy
before_script:
# Install ssh if not already
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
# Make SSH key available
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
# Add known host keys
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
only:
- main
+12
View File
@@ -0,0 +1,12 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.11.7'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/adamchainz/django-upgrade
rev: "1.24.0"
hooks:
- id: django-upgrade
args: [--target-version, "4.2"]
-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
+33
View File
@@ -0,0 +1,33 @@
# Getting started
Before starting, make sure you have a working python and nodejs setup with pyenv and nvm.
```bash
apt-get install libmysqlclient-dev
pre-commit install
uv run manage.py migrate
uv run bin/run
cp .env-default .env
# 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
- improve product listing (light purple background)
- Stats: products over time
- inventory views
- Stats: Implement users tab
## 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.
Executable → Regular
View File
File renamed without changes.
+13 -2
View File
@@ -1,10 +1,21 @@
from django.contrib import admin
from main.models import *
from bbs.models import (
InventoryTransaction,
Order,
OrderLine,
Product,
Transaction,
UserProfile,
)
@admin.register(Product)
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
File renamed without changes.
+144
View File
@@ -0,0 +1,144 @@
from typing import Annotated, Union
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 bbs import models
from bbs.api.errors import Error, FieldError, FormErrors
from bbs.api.types.inventory import Transaction
from bbs.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): # noqa: N818
amount_lacking: float
CreateOrderResponse = Annotated[
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
]
@strawberry.mutation
@transaction.atomic
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001
# 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): # noqa: N818
max_saldo: float
above: float
CreateDepositResponse = Annotated[
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
]
@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: # noqa: PLR2004
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
+33
View File
@@ -0,0 +1,33 @@
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from django.db import transaction
from strawberry import auto
from bbs import models
from bbs.api.types.users import User
@strawberry_django.input(DjangoUser)
class UserInput:
email: auto
first_name: auto
last_name: auto
@strawberry.mutation
@transaction.atomic
def register_mutation(input: UserInput) -> User: # noqa: A002
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
File renamed without changes.
+9
View File
@@ -0,0 +1,9 @@
import strawberry
import strawberry_django
from bbs.api.types.inventory import Transaction, TransactionOrdering
@strawberry.type
class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
+42
View File
@@ -0,0 +1,42 @@
import strawberry
import strawberry_django
from bbs import models
from bbs.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, ordering=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 bbs.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)
+34
View File
@@ -0,0 +1,34 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension
from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations
from bbs.api.queries.inventory import InventoryQueries
from bbs.api.queries.shop import ShopQueries
from bbs.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
class HandleNotFound(SchemaExtension):
def on_request_end(self):
result = self.execution_context.result
if result.errors:
for error in result.errors:
if isinstance(error.original_error, ObjectDoesNotExist):
error.extensions = {
"code": "NOT_FOUND",
"model": type(error.original_error).__qualname__.replace(".DoesNotExist", ""),
}
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=[HandleNotFound])
File renamed without changes.
+23
View File
@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import auto
from bbs import models
if TYPE_CHECKING:
from .users import User
@strawberry_django.order_type(models.Transaction)
class TransactionOrdering:
created: auto
@strawberry_django.type(models.Transaction)
class Transaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
amount: auto
created: auto
+78
View File
@@ -0,0 +1,78 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import auto
from bbs import models
from bbs.api.types.utils import JSONScalar
if TYPE_CHECKING:
from .users import User
@strawberry_django.filter_type(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.order_type(models.Order)
class OrderOrdering:
created: auto
@strawberry_django.type(models.Order)
class Order:
id: auto
customer: Annotated["User", strawberry.lazy(".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]
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from strawberry import auto
from bbs import models
from bbs.api.types.shop import Point, ProductOrderStat
@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: Decimal
@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(DjangoUser)
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 = "bbs"
verbose_name = "Biceps Bar System"
def ready(self):
# all models are loaded, could attach signals
pass
+29
View File
@@ -0,0 +1,29 @@
from django import forms
from bbs.models import InventoryTransaction, Product
MAX_DIGITS = 9
DECIMAL_PLACES = 1
class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.DecimalField(
max_digits=MAX_DIGITS,
decimal_places=DECIMAL_PLACES,
required=True,
localize=True,
)
def __init__(self, *args, **kwargs):
# Only show active products
super().__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(active=True)
class Meta:
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")
+1
View File
@@ -0,0 +1 @@
__author__ = "Nikolai"
+1
View File
@@ -0,0 +1 @@
__author__ = "Nikolai"
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand, CommandParser
from strawberry.printer import print_schema
from bbs.api.schema import schema as schema_symbol
class Command(BaseCommand):
help = " Write graphql schema to file or stdout"
def add_arguments(self, parser: CommandParser):
parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout")
def handle(self, *args, filename=None, **options): # noqa: ARG002
schema_output = print_schema(schema_symbol)
if filename != "-":
with Path(settings.BASE_DIR, filename).open("w+") as fp:
fp.write(schema_output)
self.stdout.write(self.style.SUCCESS("OK"))
else:
pass
+19
View File
@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand, CommandParser
from bbs.models import UserProfile
class Command(BaseCommand):
"""Set profile image URL for all users"""
def add_arguments(self, parser: CommandParser):
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002
for profile in UserProfile.objects.all().order_by("id"):
if dry_run:
pass
elif force or not profile.image:
profile.image = profile.image_url()
profile.save()
+184
View File
@@ -0,0 +1,184 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="InventoryTransaction",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.FloatField(verbose_name="Antall")),
("unit_price", models.FloatField(verbose_name="Enhetspris")),
("comment", models.TextField(verbose_name="Kommentar", blank=True)),
("created", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="Order",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("order_sum", models.FloatField(db_column="sum")),
("created", models.DateTimeField(auto_now_add=True)),
(
"customer",
models.ForeignKey(
related_name="orders",
on_delete=models.CASCADE,
blank=True,
to=settings.AUTH_USER_MODEL,
null=True,
),
),
],
),
migrations.CreateModel(
name="OrderLine",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.IntegerField()),
("unit_price", models.FloatField(verbose_name="Enhetspris")),
(
"order",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="orderlines",
to="bbs.Order",
),
),
],
),
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=64)),
("sale_price_int", models.FloatField()),
("sale_price_ext", models.FloatField()),
("description", models.CharField(max_length=256, blank=True)),
("volume_liter", models.FloatField(null=True)),
("alcohol_percent", models.FloatField(null=True)),
("image", models.ImageField(upload_to="products", blank=True)),
("customer_support", models.CharField(max_length=32, blank=True)),
("inventory_amount", models.FloatField(default=0)),
("active", models.BooleanField(default=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Transaction",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.FloatField()),
("created", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="transactions",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("balance", models.FloatField(default=0.0)),
("image", models.URLField(blank=True)),
(
"user",
models.OneToOneField(
on_delete=models.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddField(
model_name="orderline",
name="product",
field=models.ForeignKey(on_delete=models.CASCADE, related_name="orderlines", to="bbs.Product"),
),
migrations.AddField(
model_name="inventorytransaction",
name="product",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="transactions",
verbose_name="Produkt",
to="bbs.Product",
),
),
migrations.AddField(
model_name="inventorytransaction",
name="user",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="inventory_transactions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterUniqueTogether(
name="orderline",
unique_together={("order", "product")},
),
]
@@ -0,0 +1,62 @@
# Generated by Django 4.0.2 on 2022-03-05 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="inventorytransaction",
name="amount",
field=models.IntegerField(verbose_name="Antall"),
),
migrations.AlterField(
model_name="inventorytransaction",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="orderline",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="product",
name="alcohol_percent",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="product",
name="inventory_amount",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="product",
name="sale_price_ext",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="sale_price_int",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="volume_liter",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="userprofile",
name="balance",
field=models.DecimalField(decimal_places=1, default=0, max_digits=9),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2023-06-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0002_alter_inventorytransaction_amount_and_more"),
]
operations = [
migrations.AlterField(
model_name="order",
name="order_sum",
field=models.DecimalField(db_column="sum", decimal_places=1, max_digits=9),
),
]
View File
Whitespace-only changes.
Executable
+258
View File
@@ -0,0 +1,258 @@
import hashlib # noqa: EXE002
from datetime import datetime
from decimal import Decimal
from itertools import groupby
from django.conf import settings
from django.db import models
from django.db.models import Count, F, Sum
from django.db.models.functions import (
Extract,
ExtractHour,
ExtractIsoWeekDay,
ExtractMonth,
ExtractYear,
TruncDay,
TruncMonth,
)
EXTERNAL_USER = "Ekstern"
MAX_DIGITS = 9
DECIMAL_PLACES = 1
def _format_counts(per_user):
per_user = list(per_user)
for row in per_user:
if row.get("order__customer__first_name") is None:
who = EXTERNAL_USER
else:
first_name = row.pop("order__customer__first_name")
last_name = row.pop("order__customer__last_name")
last_name_initial = f" {last_name[0]}" if last_name else ""
who = f"{first_name}{last_name_initial}"
row["name"] = who
return per_user
class Product(models.Model):
name = models.CharField(max_length=64)
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
description = models.CharField(max_length=256, blank=True)
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
image = models.ImageField(upload_to="products", blank=True)
customer_support = models.CharField(max_length=32, blank=True)
# FIXME: rename to inventory_quantity
inventory_amount = models.IntegerField(default=0)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@property
def wholesale_unit_price(self):
"""
This is not accurate.
"""
if self.inventory_amount <= 0:
return 0
unit_prices = self.transactions.order_by("created").values("amount", "unit_price")
sum_units = 0
for up in unit_prices:
sum_units += up["amount"]
if self.inventory_amount <= sum_units:
return up["unit_price"]
if len(unit_prices) > 0:
return unit_prices[0]["unit_price"]
return 0
@property
def wholesale_value(self):
return self.wholesale_unit_price * self.inventory_amount
@property
def image_url(self):
return self.image.url if self.image else ""
def user_counts(self):
name_fields = ["order__customer__first_name", "order__customer__last_name"]
per_user = OrderLine.objects.filter(product=self)
per_user = per_user.values(*name_fields).annotate(count=Sum("amount")).order_by("-count")
return _format_counts(per_user)
def __str__(self): # noqa: DJ012
return self.name
class Meta: # noqa: DJ012
ordering = ["name"]
class OrderManager(models.Manager):
def count_by_created(self, extract_class: type[Extract]):
period = extract_class("created")
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
def count_by_created_period(self, period: str):
periods = {
"yearly": ExtractYear,
"monthly": ExtractMonth,
"daily": ExtractIsoWeekDay,
"hourly": ExtractHour,
}
assert period in periods # noqa: S101
return self.count_by_created(periods[period])
class Order(models.Model):
customer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="orders",
)
order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
objects = OrderManager()
def is_external(self):
return self.customer is None
def reciept(self):
return ", ".join([str(ol) for ol in self.orderlines.all()])
def __str__(self): # noqa: DJ012
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
class OrderLineManager(models.QuerySet):
def product_order_stats(self):
return (
self.annotate(date=TruncDay("order__created"))
.values("date", product_name=F("product__name"))
.annotate(count=Sum("amount"))
.order_by("date")
)
def as_series(self):
datas = list(self)
datas.sort(key=lambda x: x["product_name"])
return [
{
"product_name": key,
"data": [{"x": item["date"].date().isoformat(), "y": item["count"]} for item in list(group)],
}
for key, group in groupby(datas, key=lambda x: x["product_name"])
]
class OrderLine(models.Model):
order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines")
product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines")
# FIXME: rename to quantity
amount = models.IntegerField()
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
objects = OrderLineManager.as_manager()
@property
def price(self):
return self.amount * self.unit_price
def __str__(self): # noqa: DJ012
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
class Meta: # noqa: DJ012
unique_together = ("order", "product")
class Transaction(models.Model):
"""Money"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.id}: {self.user}: {self.amount}"
class InventoryTransaction(models.Model):
"""Goods"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="inventory_transactions",
)
product = models.ForeignKey(
"bbs.Product",
on_delete=models.CASCADE,
related_name="transactions",
verbose_name="Produkt",
)
# FIXME: rename to quantity
amount = models.IntegerField(verbose_name="Antall")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
comment = models.TextField(verbose_name="Kommentar", blank=True)
created = models.DateTimeField(auto_now_add=True)
@property
def price(self):
return self.amount * self.unit_price
def __str__(self): # noqa: DJ012
return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)"
class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0)
image = models.URLField(blank=True)
def last_purchase(self):
return self.user.orders.order_by("-created").first()
def last_purchase_date(self) -> datetime:
last = self.last_purchase()
return last.created if last else datetime.min # noqa: DTZ901
def top_months(self, limit=5):
return (
Order.objects.filter(customer_id=self.user_id)
.annotate(period=TruncMonth("created"))
.values("period")
.annotate(sum=Sum("order_sum"), count=Count("pk"))
.order_by("-sum")[:limit]
)
def product_order_stats(self):
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
def order_sum_total(self) -> Decimal:
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
def image_url(self) -> str:
if self.user.email:
md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest() # noqa: S324
return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
return ""
def save(self, **kwargs): # noqa: DJ012
if not self.image:
self.image = self.image_url()
super().save(**kwargs)
def __str__(self): # noqa: DJ012
return f"{self.user}"
+146
View File
@@ -0,0 +1,146 @@
# Django settings for bbs project. # noqa: EXE002
import contextlib
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
DEBUG = True
ADMINS = (
# ('Your Name', 'your_email@example.com'), # noqa: ERA001
)
MANAGERS = ADMINS
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "db.sqlite3",
},
}
TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "nb"
USE_I18N = True
USE_TZ = True
SITE_ID = 1
# Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
MEDIA_URL = "/media/"
# Static files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
STATIC_URL = "/static/"
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
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" # noqa: S105
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"), # noqa: PTH118
],
"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 = "bbs.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",
"django_rename_app",
]
LOCAL_APPS = [
"bbs",
]
INSTALLED_APPS += LOCAL_APPS
LOGIN_REDIRECT_URL = "/"
SESSION_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
BBS_SALDO_MAX = 1000
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",
]
with contextlib.suppress(ImportError):
from .local_settings import * # noqa: F403
+52
View File
@@ -0,0 +1,52 @@
import pytest # noqa: INP001
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.models import Order, Product
@pytest.fixture(name="gql_client")
def gql_client_fixture(client: Client):
return GraphQLTestClient(client)
@pytest.fixture(name="product")
def product_fix(db): # noqa: ARG001
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
def test_create_order(product, gql_client):
assert Order.objects.count() == 0
variables = {
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
... on CreateOrderSuccess {
order {
id
orderSum
orderlines {
id
product {
id
}
}
}
}
}
}
"""
res = gql_client.query(query, variables)
assert res
assert not res.errors
assert res.data
order_lines = res.data.get("createOrder", {}).get("order", {}).get("orderlines", [])
assert order_lines
assert order_lines[0]["product"]["id"] == str(product.id)
assert Order.objects.count() == 1
o = Order.objects.first()
assert o.orderlines.first().product.id == product.id
+30
View File
@@ -0,0 +1,30 @@
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 path
from django.views.decorators.csrf import csrf_exempt
from strawberry.django.views import GraphQLView
from bbs.api.schema import schema
from bbs.views.reports import inventory, inventory_add, report
admin.autodiscover()
urlpatterns = [
# TODO: migrate reports to SPA
path("inventory/", inventory, name="inventory"),
path("inventory/add/", inventory_add, name="inventory-add"),
path("report", report, name="report"),
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/", csrf_exempt(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)
+5
View File
@@ -0,0 +1,5 @@
def format_username(firstname: str, lastname: str):
first = firstname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
last = lastname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
return f"{first}{last}"
View File
Whitespace-only changes.
+144
View File
@@ -0,0 +1,144 @@
from datetime import datetime, timedelta
from itertools import groupby
from django.contrib import messages
from django.shortcuts import redirect, render
from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
def report(request):
# FIXME: split this up in smaller pieces
datetime_now = datetime.now() # noqa: DTZ005
start_time = datetime.now() - timedelta(days=30) # noqa: DTZ005
# start of day, 1 month
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
# end of day
end_time = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999) # noqa: DTZ005
if request.GET.get("start_time", False) or request.GET.get("end_time", False):
form = DateRangeForm(request.GET)
if form.is_valid():
start_time = form.cleaned_data["start_time"]
# end of day
end_time = form.cleaned_data["end_time"].replace(hour=23, minute=59, second=59, microsecond=999)
else:
form = DateRangeForm(
initial={
"start_time": start_time.strftime("%Y-%m-%d"),
"end_time": end_time.strftime("%Y-%m-%d"),
}
)
# Inventory
products = (
Product.objects.filter(transactions__created__range=[start_time, end_time])
.values("name", "transactions__unit_price", "transactions__amount")
.order_by("name")
)
# group by name and sum
inventory_products = {}
for k, g in groupby(products, lambda x: x["name"]):
group = []
for el in g:
el["transactions__price"] = el["transactions__amount"] * el["transactions__unit_price"]
group.append(el)
inventory_products.update(
{
k: {
"name": k,
"transactions": group,
"transactions_sum": sum(x["transactions__price"] for x in group),
"transactions_units": sum(x["transactions__amount"] for x in group),
}
}
)
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items())
# out
out_products = (
Product.objects.filter(orderlines__order__created__range=[start_time, end_time])
.values("name", "orderlines__unit_price", "orderlines__amount")
.order_by("name")
)
# group by name and sum
for k, g in groupby(out_products, lambda x: x["name"]):
group = []
for el in g:
el["orderlines__price"] = el["orderlines__amount"] * el["orderlines__unit_price"]
group.append(el)
product = {
"name": k,
"orderlines": group,
"orderlines_sum": sum(x["orderlines__price"] for x in group),
"orderlines_units": sum(x["orderlines__amount"] for x in group),
}
if k in inventory_products:
# add diff
inventory_products[k].update(product)
else:
inventory_products.update({k: product})
for p in inventory_products.items():
p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0)
p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0)
inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items())
inv_diff = inv_in - inv_out
# Deposits and sales
saldo_profiles = sum(UserProfile.objects.all().values_list("balance", flat=True))
deposits = sum(Transaction.objects.all().values_list("amount", flat=True))
purchases = sum(Order.objects.all().values_list("order_sum", flat=True))
saldo = deposits - purchases
deposits_start = sum(Transaction.objects.filter(created__lt=start_time).values_list("amount", flat=True))
purchases_start = sum(Order.objects.filter(created__lt=start_time).values_list("order_sum", flat=True))
saldo_start = deposits_start - purchases_start
deposits_end = sum(Transaction.objects.filter(created__lt=end_time).values_list("amount", flat=True))
purchases_end = sum(Order.objects.filter(created__lt=end_time).values_list("order_sum", flat=True))
saldo_end = deposits_end - purchases_end
deposits_diff = deposits_end - deposits_start
purchases_diff = purchases_end - purchases_start
saldo_diff = saldo_end - saldo_start
return render(request, "report.html", locals()) # noqa: DJ003
def inventory(request):
products = Product.objects.all().order_by("-active", "inventory_amount", "name")
transactions = InventoryTransaction.objects.all().order_by("-created")
products_active = products.filter(active=True)
wholesale_value = sum([p.wholesale_value for p in products_active])
num_products = sum(products_active.values_list("inventory_amount", flat=True))
return render(request, "inventory.html", locals()) # noqa: DJ003
def inventory_add(request):
if request.method == "POST":
form = InventoryTransactionForm(data=request.POST)
if form.is_valid():
itrans = form.save(commit=False)
# Add amount and user, then save
itrans.product.inventory_amount += itrans.amount
itrans.user = request.user
itrans.product.save()
itrans.save()
messages.success(request, f"Satt inn {itrans.amount} stk {itrans.product}.")
return redirect("inventory-add")
messages.error(request, "Feil med skjemaet, se under.")
form = InventoryTransactionForm(data=request.POST)
else:
form = InventoryTransactionForm()
return render(request, "inventory_add.html", locals()) # noqa: DJ003
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbs.settings")
application = get_wsgi_application()
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -eu
cd frontend
npm i
npm run build
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -eu
source "$HOME/.local/bin/env"
export VITE_API_URL="https://bbs.neuf.no/api"
PROJECT_PATH=/var/www/neuf.no/bbs
PROCESS_NAME="bbs.neuf.no"
echo "Deploying bbs to ${PROJECT_PATH}"
cd "$PROJECT_PATH" || exit 1
git pull
# Backend
uv sync
umask 022; uv run manage.py collectstatic --noinput # Collect static
uv run manage.py migrate # Run DB migrations
# Frontend
bin/build-frontend
# Reload backend
echo "Reloading supervisor process ${PROCESS_NAME}"
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
python manage.py export_schema
cd frontend && npm run generate
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
uvx pre-commit run --all-files
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
python manage.py runserver
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
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 @@
v20
+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
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
<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>
+21988
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
{
"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.10",
"@nivo/bar": "^0.79.1",
"@nivo/line": "^0.79.1",
"@nivo/pie": "^0.79.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.30.0",
"react-router-dom": "^6.3.0",
"use-url-search-params": "^2.5.1"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/typed-document-node": "^2.2.8",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@graphql-typed-document-node/core": "^3.1.1",
"@types/react": "^17.0.44",
"@types/react-dom": "^17.0.15",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@typescript-eslint/parser": "^5.19.0",
"@vitejs/plugin-react": "^1.3.1",
"@vitejs/plugin-react-refresh": "^1.3.6",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"jest": "^27.5.1",
"prettier": "^2.6.2",
"stylelint": "^14.7.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.6.3",
"vite": "^2.9.5",
"vite-plugin-svgr": "^1.1.0"
},
"engines": {
"node": "20"
}
}
+4
View File
@@ -0,0 +1,4 @@
.app {
margin: 1rem auto;
max-width: 1220px;
}
+30
View File
@@ -0,0 +1,30 @@
import { ApolloProvider } from "@apollo/client";
import React from "react";
import { BrowserRouter as Router } from "react-router-dom";
import styles from "./App.module.css";
import { Routes } from "./Routes";
import client from "./client";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header";
import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider";
import { Wireframe } from "./components/Wireframe";
const App = (): JSX.Element => (
<ApolloProvider client={client}>
<Router>
<ToastProvider>
<Header />
<div className={styles.app}>
<Routes />
<Wireframe />
</div>
<Footer />
<Toast />
</ToastProvider>
</Router>
</ApolloProvider>
);
export default App;
+30
View File
@@ -0,0 +1,30 @@
import React from "react";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
const Splash = React.lazy(() => import("./components/Splash"));
const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
const Log = React.lazy(() => import("./features/log/Log"));
const Register = React.lazy(() => import("./features/register/Register"));
const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
const Stats = React.lazy(() => import("./features/stats/Stats"));
const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes = (): JSX.Element => (
<React.Suspense fallback={<div>Loading...</div>}>
<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 />} />
<Route path="*" element={<Splash brandText="404" noOverlay />} />
</ReactRouterRoutes>
</React.Suspense>
);
+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

Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: `${import.meta.env.VITE_API_URL}/graphql/`,
cache: new InMemoryCache(),
});
export default client;
+22
View File
@@ -0,0 +1,22 @@
.footer {
position: absolute;
right: 0;
bottom: 1rem;
left: 0;
height: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
text-align: center;
}
.heart {
color: var(--brand);
}
.brick {
position: relative;
top: 7px;
display: inline-block;
width: 20px;
height: 20px;
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import { ReactComponent as Brick } from "../assets/brick.svg";
import styles from "./Footer.module.css";
export const Footer = (): JSX.Element => {
return (
<footer className={styles.footer}>
Made with <span className={styles.heart}>❤</span> by{" "}
<span title="EDB-gjengen">
<Brick className={styles.brick} />
</span>
</footer>
);
};
@@ -0,0 +1,9 @@
.errorMessage {
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
color: var(--brand);
text-align: center;
width: 100%;
margin-top: 30vh;
}
+23
View File
@@ -0,0 +1,23 @@
import { ApolloError } from "@apollo/client";
import React from "react";
import styles from "./GQLError.module.css";
const formatModel = (text: string) => {
const withSpaces = text
.replace(/([A-Z])/g, " $1")
.toLocaleLowerCase()
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
};
export const GQLError = ({ error }: { error: ApolloError }) => {
if (error?.graphQLErrors?.length == 1) {
const single = error?.graphQLErrors[0];
if (single?.extensions?.code === "NOT_FOUND") {
const modelPretty = formatModel(single?.extensions?.model as string);
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
}
}
return <div className={styles.errorMessage}>Error...</div>;
};
+94
View File
@@ -0,0 +1,94 @@
.header {
margin: 1rem auto;
max-width: 1220px;
}
.nav {
display: flex;
height: 5rem;
align-items: center;
text-transform: uppercase;
}
.navLink:after {
position: absolute;
right: 12px;
left: 14px;
display: block;
height: 1px;
background-color: rgba(243, 33, 61, 0.4);
box-shadow: 0 0 4px rgba(243, 33, 61, 0.4), 0 0 11px rgba(243, 33, 61, 0.4), 0 0 19px rgba(243, 33, 61, 0.4);
content: "";
transform: rotate(-2.5deg);
}
.navLink:hover:after {
background-color: var(--brand);
box-shadow: 0 0 4px var(--brand), 0 0 11px var(--brand), 0 0 19px var(--brand), 0 0 30px var(--brand);
}
.logoNav {
margin-left: 8px;
}
.navItems {
display: flex;
align-items: center;
padding: 0;
margin: 0;
list-style: none;
}
.navLink {
position: relative;
padding: 0.4rem 0.8rem;
border: 1px solid transparent;
transition: color 0.1s ease-in-out;
}
.navLink:hover {
color: white;
}
.active.active {
color: white;
}
/* FIXME: Duplicates Splash styles */
.brand {
padding-left: 1rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
left: -8px;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: 10px;
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+55
View File
@@ -0,0 +1,55 @@
import clsx from "clsx";
import React from "react";
import { NavLink } from "react-router-dom";
import styles from "./Header.module.css";
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const Header = (): JSX.Element => {
return (
<header className={styles.header}>
<nav className={styles.nav}>
<NavLink to="/" className={styles.logoNav}>
<div className={styles.brand}>BBS</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</NavLink>
<ul className={styles.navItems}>
<li>
<NavLink to="/" className={navLinkStyles} end>
Kjøp drikke
</NavLink>
</li>
<li>
<NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger
</NavLink>
</li>
<li>
<NavLink to="/log" className={navLinkStyles}>
Logg
</NavLink>
</li>
<li>
<NavLink to="/stats" className={navLinkStyles}>
Statistikk
</NavLink>
</li>
<li>
<NavLink to="/users" className={navLinkStyles}>
Folk
</NavLink>
</li>
<li>
<a href={ADMIN_URL} className={styles.navLink}>
Admin
</a>
</li>
</ul>
</nav>
</header>
);
};
+68
View File
@@ -0,0 +1,68 @@
.splash {
top: 0;
right: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
text-align: center;
}
.overlay {
position: fixed;
}
.wrap {
position: relative;
}
.sun {
width: clamp(100px, 65vw, 600px);
height: clamp(100px, 65vw, 600px);
margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
border-radius: 50% 50%;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun), 0 0 30px var(--sun);
}
.brand {
position: relative;
left: -0.6rem;
padding-top: 3rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: clamp(20px, 20vw, 200px);
font-style: italic;
letter-spacing: 1rem;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
bottom: 0.5rem;
left: 0rem;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: clamp(20px, 5vw, 50px);
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+20
View File
@@ -0,0 +1,20 @@
import clsx from "clsx";
import React from "react";
import styles from "./Splash.module.css";
interface SplashProps {
brandText?: string;
noOverlay?: boolean;
}
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
<div className={clsx(styles.splash, { [styles.overlay]: !noOverlay })}>
<div className={styles.wrap}>
<div className={styles.sun}></div>
<div className={styles.brand}>{brandText}</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</div>
</div>
);
export default Splash;
+28
View File
@@ -0,0 +1,28 @@
.toast {
position: absolute;
bottom: 32px;
left: 32px;
padding: 0.6rem 1.2rem;
border-radius: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease-in-out;
}
.visible {
opacity: 1;
pointer-events: initial;
}
.primary {
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
}
.danger {
background: var(--brand);
box-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.close {
display: none;
}
+18
View File
@@ -0,0 +1,18 @@
import clsx from "clsx";
import React from "react";
import styles from "./Toast.module.css";
import { useToast } from "./ToastProvider";
export const Toast = (): JSX.Element => {
const { showToast, message, background, visible } = useToast();
const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
return (
<div className={classes} role="alert" aria-live="assertive" aria-atomic="true">
<div>{message}</div>
<button type="button" aria-label="Close" onClick={() => showToast(message)} className={styles.close} />
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
import React, { ReactNode, useContext, useState } from "react";
type Background = "primary" | "danger";
interface ToastProps {
visible: boolean;
message: string;
showToast: (message: string, background?: Background) => void;
hide: () => void;
background: Background;
}
const defaultState = {
// FIXME: revert
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" as Background,
};
const ToastContext = React.createContext<ToastProps>(defaultState);
type ToastProviderProps = {
children: ReactNode;
};
export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
const [visible, setVisible] = useState(defaultState.visible);
const [message, setMessage] = useState(defaultState.message);
const [background, setBackground] = useState(defaultState.background);
const showToast = (newMessage: string, background: Background = "primary") => {
setMessage(newMessage);
setBackground(background);
if (!visible) {
setVisible(true);
setTimeout(() => {
setVisible(false);
}, 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,38 @@
.wireframe {
position: absolute;
bottom: calc(22% - 512px);
left: calc(50% - 512px);
width: 1024px;
height: 1024px;
display: grid;
grid-template-columns: repeat(8, 128px);
animation-iteration-count: infinite;
animation-name: isometric-rotate;
animation-duration: 10s;
animation-timing-function: linear;
pointer-events: none;
z-index: -1;
}
.cell {
border-bottom: 2px solid var(--sun);
border-right: 2px solid var(--sun);
}
.cell:nth-child(8n) {
border-right: none;
}
.cell:nth-child(n+57) {
border-bottom: none;
}
@keyframes isometric-rotate {
0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
}
100% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-405deg);
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import styles from "./Wireframe.module.css";
export const Wireframe = () => (
<div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} />
))}
</div>
);
@@ -0,0 +1,5 @@
.form {
display: grid;
max-width: 500px;
gap: 1rem;
}
+51
View File
@@ -0,0 +1,51 @@
import { useMutation } from "@apollo/client";
import React from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { CreateUserDocument } from "../../types";
import styles from "./UserCreate.module.css";
type FormValues = {
firstName: string;
lastName: string;
email: string;
};
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 (
<div>
<h2>Opprett ny</h2>
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<label>Fornavn</label>
<input {...register("firstName", { required: true })} />
{errors.firstName && <span>This field is required</span>}
<label>Etternavn</label>
<input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>}
<label>E-post</label>
<input type="email" {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
<input type="submit" disabled={creating} className="btn-primary" />
</form>
</div>
);
};
export default UserCreate;
@@ -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,17 @@
.userList {
display: grid;
height: 800px;
padding-top: 2rem;
grid-template-columns: repeat(5, 220px);
overflow-y: auto;
column-gap: 16px;
row-gap: 24px;
}
.amountSection {
margin-top: 32px;
}
.checkout {
margin-top: 32px;
}
+68
View File
@@ -0,0 +1,68 @@
import React from "react";
import { UserCard } from "../users/UserCard";
import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit";
const Deposit = (): JSX.Element => {
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 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" onClick={onSubmit} className="primary">
Sett inn
</button>{" "}
<button type="button" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
export default Deposit;
@@ -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,
};
};
+32
View File
@@ -0,0 +1,32 @@
.log {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
.disabled,
.disabled a:link {
color: rgba(121, 152, 238, 0.5);
}
.pagination {
display: flex;
align-items: flex-end;
padding: 0;
margin: 0;
margin-top: 1.5rem;
gap: 0 1rem;
list-style: none;
}
.pagination a {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
margin-top: 1rem;
border-radius: 4px;
}
.pagination a:hover {
color: white;
}
.disabled a:hover {
color: rgba(121, 152, 238, 0.5);
}
+142
View File
@@ -0,0 +1,142 @@
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;
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>
<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}>
{Number(line.amount)}x {line.product.name}
</div>
))}
</td>
<td>{Number(order.orderSum)}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Order pagination">
<ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: orderOffset <= 0 })}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: Math.max(0, orderOffset - PAGE_SIZE) });
}}
>
⬅
</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: Math.max(0, orderOffset + PAGE_SIZE) });
}}
>
➡
</a>
</li>
</ul>
</nav>
</div>
<div>
<h2>Transaksjoner</h2>
<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>{Number(trans.amount)}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Transaction pagination">
<ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: Math.max(0, transactionOffset - PAGE_SIZE) });
}}
>
⬅
</a>
</li>
<li>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: Math.max(transactionOffset + PAGE_SIZE) });
}}
>
➡
</a>
</li>
</ul>
</nav>
</div>
</div>
);
};
export default Log;
@@ -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,51 @@
.product {
display: flex;
border: none;
background: rgb(35, 15, 41);
border-radius: 4px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
position: relative;
align-items: flex-start;
padding: 1rem;
}
.product:hover,
.active.product {
background-color: var(--background);
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
}
.productText {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.2rem;
text-shadow: none;
color: white;
}
.amount {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
right: 0;
padding: 12px 12px 8px 8px;
border-left: 1px solid transparent;
color: transparent;
font-size: 2rem;
font-weight: bold;
line-height: 1;
background-color: transparent;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
display: none;
}
.active .amount {
display: block;
color: var(--background);
text-shadow: none;
border-left-color: rgb(35, 15, 41);
background-color: var(--sun-end);
}
@@ -0,0 +1,22 @@
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 = ({ product, onProduct, amount, active = false }: ProductCardProps): JSX.Element => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct} title={product.name}>
<span className={styles.amount}>{amount}</span>
<span className={styles.productText}>{product.name}</span>
</button>
);
};
@@ -0,0 +1,34 @@
.userList {
display: grid;
grid-template-columns: repeat(5, 220px);
column-gap: 16px;
row-gap: 24px;
}
.showAll {
margin-top: 1rem;
}
.productList {
display: grid;
grid-template-columns: repeat(5, 220px);
column-gap: 16px;
row-gap: 24px;
}
.productListSection {
margin-top: 2rem;
}
.checkout {
margin-top: 32px;
}
.total {
display: inline-block;
min-width: 200px;
padding: 4px 8px;
margin-right: 8px;
color: white;
font-size: 1.8rem;
}
@@ -0,0 +1,83 @@
import React from "react";
import { OrderLineInput, Product } from "../../types";
import { UserCard } from "../users/UserCard";
import { ProductCard } from "./ProductCard";
import styles from "./Register.module.css";
import { useRegister } from "./useRegister";
const USER_EXTERNAL = "external";
const Register = (): JSX.Element => {
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" onClick={() => setShowAll(!showAll)} className={styles.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" onClick={onSubmit} className="primary">
Kjøp
</button>{" "}
<button type="button" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
export default Register;
@@ -0,0 +1,9 @@
query allProducts($active: Boolean!) {
allProducts(filters: {active: $active}) {
id
name
imageUrl
salePriceExt
salePriceInt
}
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more