initial commit
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# Создание рабочей среды для сборки
|
||||
FROM maven:3.6.3-openjdk-17-slim as build
|
||||
RUN mkdir -p /build/source
|
||||
COPY . /build/source
|
||||
WORKDIR /build/source
|
||||
RUN mvn -am clean package
|
||||
# Создание образа my-bar
|
||||
FROM openjdk:17-ea-jdk-slim as my-bar
|
||||
COPY --from=build /build/source/target/*.jar /data/app/my-bar.jar
|
||||
WORKDIR /data/app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT "java" $JAVA_OPTS "-jar" "/data/app/my-bar.jar"
|
||||
0
front/.env
Normal file
23
front/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
3
front/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:1.16.0-alpine
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
70
front/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
26
front/default.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://192.168.1.101:8080;
|
||||
}
|
||||
|
||||
# Настройка кэширования статических ресурсов
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Включение сжатия Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain application/javascript application/json application/xml;
|
||||
}
|
||||
19344
front/package-lock.json
generated
Normal file
66
front/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource/inter": "5.0.18",
|
||||
"@fontsource/plus-jakarta-sans": "5.0.20",
|
||||
"@fontsource/roboto": "^5.0.14",
|
||||
"@fontsource/roboto-mono": "5.0.18",
|
||||
"@hookform/resolvers": "3.6.0",
|
||||
"@mui/icons-material": "^5.16.6",
|
||||
"@mui/material": "^5.16.6",
|
||||
"@mui/utils": "^5.16.6",
|
||||
"@mui/x-date-pickers": "7.7.1",
|
||||
"@phosphor-icons/react": "2.1.6",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"apexcharts": "3.49.2",
|
||||
"axios": "^1.7.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "1.11.11",
|
||||
"moment": "^2.30.1",
|
||||
"notistack": "^3.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-apexcharts": "1.4.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "7.52.0",
|
||||
"react-jwt": "^1.2.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.1",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
front/public/assets/avatar.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
BIN
front/public/assets/cocktails/1723685244471_bulletin.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
front/public/assets/cocktails/herosim.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
front/public/assets/cocktails/morangeyto.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
front/public/assets/cocktails/morangeyto_c.jpg
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
front/public/assets/error-404.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
front/public/assets/ingredients/Aperol.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
23
front/public/assets/logo--dark.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#212636" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
|
||||
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
|
||||
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
|
||||
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
|
||||
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
|
||||
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
|
||||
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
|
||||
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
|
||||
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
front/public/assets/logo-emblem--dark.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#212636" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
|
||||
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
|
||||
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
|
||||
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
|
||||
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
|
||||
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
|
||||
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
|
||||
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
|
||||
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
front/public/assets/logo-emblem.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#ffffff" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
|
||||
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
|
||||
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
|
||||
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
|
||||
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
|
||||
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
|
||||
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
|
||||
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
|
||||
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
front/public/assets/logo.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#ffffff" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
|
||||
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
|
||||
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
|
||||
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
|
||||
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
|
||||
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
|
||||
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
|
||||
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
|
||||
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
front/public/assets/qr.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
front/public/background.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
front/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
front/public/img/background.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
43
front/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Набор коктейлей для вашего бара"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>My Little Bar</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
front/public/logo192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
front/public/logo512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
15
front/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "MyBar",
|
||||
"name": "My Little Bar",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
front/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
7
front/src/Config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import {getSiteURL} from "./lib/getSiteUrl";
|
||||
import {LogLevel} from "./lib/Logger";
|
||||
|
||||
export const config = {
|
||||
site: {name: 'Bar', description: '', themeColor: '#090a0b', url: getSiteURL()},
|
||||
logLevel: (process.env.NEXT_PUBLIC_LOG_LEVEL) ?? LogLevel.ALL,
|
||||
};
|
||||
5
front/src/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM nginx:alpine as nginx
|
||||
WORKDIR /app
|
||||
COPY ../nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD "nginx" "-g" "daemon off;"
|
||||
55
front/src/app/App.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import {CssBaseline, GlobalStyles} from "@mui/material";
|
||||
import {LocalizationProvider} from "../components/core/LocalizationProvider";
|
||||
import {AuthProvider} from "../context/AuthContext";
|
||||
import {createTTheme} from "../styles/theme/create-theme";
|
||||
import {Experimental_CssVarsProvider as CssVarsProvider} from '@mui/material/styles';
|
||||
import {BrowserRouter as Router} from "react-router-dom";
|
||||
import {NavigationRoutes} from "./NavigationRoutes";
|
||||
import {SnackbarProvider} from 'notistack';
|
||||
import {UserProvider} from "../context/UserContext";
|
||||
import {SelectProvider} from "../context/SelectContext";
|
||||
|
||||
function App() {
|
||||
const theme = createTTheme();
|
||||
|
||||
return (
|
||||
// Провайдер времени
|
||||
<LocalizationProvider>
|
||||
{/*Провайдер уведомлений*/}
|
||||
<SnackbarProvider maxSnack={6} anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
|
||||
style={{borderRadius: '10px'}}>
|
||||
{/*Провайдер авторизации*/}
|
||||
<AuthProvider>
|
||||
{/*Провайдер пользователя*/}
|
||||
<UserProvider>
|
||||
{/*Провайдер темы*/}
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline/>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
'--MainNav-height': '56px',
|
||||
'--MainNav-zIndex': 1000,
|
||||
'--SideNav-width': '280px',
|
||||
'--SideNav-zIndex': 1200,
|
||||
'--MobileNav-width': '320px',
|
||||
'--MobileNav-zIndex': 1200,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/*Провайдер выбора*/}
|
||||
<SelectProvider>
|
||||
{/*Маршрутизация*/}
|
||||
<Router>
|
||||
<NavigationRoutes/>
|
||||
</Router>
|
||||
</SelectProvider>
|
||||
</CssVarsProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
10
front/src/app/HomeRedirect.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {paths} from "../path";
|
||||
import {Loading} from "../components/core/Loading";
|
||||
|
||||
export function HomeRedirect({auth}) {
|
||||
const redirectPath = auth ? paths.dashboard.overview : paths.auth.signIn;
|
||||
window.location.replace(redirectPath);
|
||||
return (
|
||||
<Loading loading={true}/>
|
||||
)
|
||||
}
|
||||
132
front/src/app/NavigationRoutes.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import {Route, Routes} from "react-router-dom";
|
||||
import {paths} from "../path";
|
||||
import {useAuth} from "../hooks/useAuth";
|
||||
import NotFoundPage from "./pages/notFound/NotFoundPage";
|
||||
import {UserLayout} from "./layout/UserLayout";
|
||||
import {HomeRedirect} from "./HomeRedirect";
|
||||
import {PublicLayout} from "./layout/PublicLayout";
|
||||
import LoginPage from "./pages/auth/sign-in/loginPage";
|
||||
import {TelegramCode} from "./pages/auth/sign-in/telegram-code";
|
||||
import {IngredientsPage} from "./pages/ingredients/IngredientsPage";
|
||||
import {MenuPage} from "./pages/cocktails/MenuPage";
|
||||
import {EditIngredientPage} from "./pages/ingredients/EditIngredientPage";
|
||||
import {EditCocktailPage} from "./pages/cocktails/EditCocktailPage";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BarChangePage} from "./pages/BarChangePage";
|
||||
import {CalcPage} from "./pages/calc/CalcPage";
|
||||
|
||||
export function NavigationRoutes() {
|
||||
const {auth} = useAuth();
|
||||
const [loadedRoutes, setLoadedRoutes] = useState(undefined);
|
||||
useEffect(() => {
|
||||
setLoadedRoutes(auth ? authPages : guestPages)
|
||||
}, [auth]);
|
||||
if (!loadedRoutes) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
{loadedRoutes.map((page) => {
|
||||
return (
|
||||
<Route
|
||||
key={page.path + page.isPrivate + page.exact}
|
||||
path={page.path}
|
||||
exact={page.exact}
|
||||
element={<ElementProvider isPrivate={page.isPrivate}>
|
||||
{page.children}
|
||||
</ElementProvider>}/>
|
||||
)
|
||||
})}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function ElementProvider({isPrivate, children}) {
|
||||
if (isPrivate) {
|
||||
return (<UserLayout>{children}</UserLayout>);
|
||||
} else {
|
||||
return (<PublicLayout>{children}</PublicLayout>);
|
||||
}
|
||||
}
|
||||
|
||||
const authPages = [
|
||||
{
|
||||
children: (<HomeRedirect auth={true}/>),
|
||||
isPrivate: false,
|
||||
path: paths.home,
|
||||
},
|
||||
{
|
||||
path: paths.auth.signIn,
|
||||
children: (<LoginPage/>),
|
||||
isPrivate: false,
|
||||
},
|
||||
{
|
||||
path: paths.bar.calc,
|
||||
children: (<CalcPage/>),
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: paths.dashboard.overview,
|
||||
isPrivate: true,
|
||||
children: (<MenuPage/>),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: paths.bar.list,
|
||||
isPrivate: true,
|
||||
children: (<BarChangePage/>),
|
||||
},
|
||||
{
|
||||
path: paths.bar.ingredients,
|
||||
isPrivate: true,
|
||||
children: (<IngredientsPage/>)
|
||||
},
|
||||
{
|
||||
path: paths.bar.ingredientEdit,
|
||||
isPrivate: true,
|
||||
forAdmin: true,
|
||||
children: (<EditIngredientPage/>)
|
||||
},
|
||||
{
|
||||
path: paths.bar.cocktailEdit,
|
||||
isPrivate: true,
|
||||
forAdmin: true,
|
||||
children: (<EditCocktailPage/>)
|
||||
},
|
||||
{
|
||||
path: paths.notFound,
|
||||
isPrivate: false,
|
||||
children: (<NotFoundPage/>)
|
||||
},
|
||||
]
|
||||
|
||||
const guestPages = [
|
||||
{
|
||||
path: paths.dashboard.overview,
|
||||
isPrivate: true,
|
||||
children: (<MenuPage/>),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
children: (<HomeRedirect auth={true}/>),
|
||||
isPrivate: false,
|
||||
path: paths.home,
|
||||
},
|
||||
{
|
||||
path: paths.auth.tg,
|
||||
isPrivate: false,
|
||||
children: (<TelegramCode/>),
|
||||
exact: false
|
||||
},
|
||||
{
|
||||
path: paths.auth.signIn,
|
||||
isPrivate:
|
||||
false,
|
||||
children: (<LoginPage/>),
|
||||
},
|
||||
{
|
||||
path: paths.notFound,
|
||||
isPrivate: false,
|
||||
children: (<NotFoundPage/>),
|
||||
},
|
||||
]
|
||||
55
front/src/app/layout/PublicLayout.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {DynamicLogo} from "../../components/core/Logo";
|
||||
import {paths} from "../../path";
|
||||
|
||||
export function PublicLayout({ children }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'flex', lg: 'grid' },
|
||||
flexDirection: 'column',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box component={'a'} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
|
||||
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ alignItems: 'center', display: 'flex', flex: '1 1 auto', justifyContent: 'center', p: 3 }}>
|
||||
<Box sx={{ maxWidth: '450px', width: '100%' }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
background: 'radial-gradient(50% 50% at 50% 50%, #122647 0%, #090E23 100%)',
|
||||
color: 'var(--mui-palette-common-white)',
|
||||
display: { xs: 'none', lg: 'flex' },
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={1}>
|
||||
<Typography color="inherit" sx={{ fontSize: '24px', lineHeight: '32px', textAlign: 'center' }} variant="h1">
|
||||
<Box component="span" sx={{ color: '#15b79e' }}>
|
||||
Добро пожаловать в бар
|
||||
</Box>
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
alt="Under development"
|
||||
src="/assets/qr.png"
|
||||
sx={{ display: 'inline-block', height: 'auto', maxWidth: '100%', width: '400px' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
31
front/src/app/layout/UserLayout.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import {SideNav} from "../../components/navigation/SideNav";
|
||||
import Box from "@mui/material/Box";
|
||||
import {MainNav} from "../../components/navigation/MainNav";
|
||||
import Container from "@mui/material/Container";
|
||||
|
||||
export function UserLayout({children}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-default)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<SideNav/>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
pl: {xl: 'var(--SideNav-width)'}
|
||||
}}>
|
||||
<MainNav/>
|
||||
<Container maxWidth="xl" sx={{py: '16px'}}>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
79
front/src/app/pages/BarChangePage.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import Paper from "@mui/material/Paper";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import {Card} from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Box from "@mui/material/Box";
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices';
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import {BarCreateModal} from "../../components/BarCreateModal";
|
||||
import PowerIcon from '@mui/icons-material/Power';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import {barClient} from "../../lib/clients/BarClient";
|
||||
|
||||
export function BarChangePage() {
|
||||
const [bars, setBars] = useState([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [oldId, setOldId] = useState(null);
|
||||
const {createError, createSuccess, createWarning} = useAlert();
|
||||
|
||||
const createHandler = (id, name) => {
|
||||
if (id) {
|
||||
barClient.copyBar(id, name, setBars, bars, createError, createSuccess, setOpen)
|
||||
} else {
|
||||
barClient.createBar(name, bars, createSuccess, createError, setBars, setOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
useEffect(() => barClient.getBarList(setBars, createError), []);
|
||||
|
||||
return (<>
|
||||
<BarCreateModal open={open} setOpen={setOpen} create={createHandler} id={oldId}/>
|
||||
<Paper sx={{p: 1}}>
|
||||
<Toolbar>
|
||||
<Typography variant='h6'>Списки ингредиентов (бары)</Typography>
|
||||
<IconButton edge="end" onClick={() => {
|
||||
setOldId(null);
|
||||
setOpen(true);
|
||||
}}>
|
||||
<AddCircleIcon/>
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
{bars.map((b) => {
|
||||
return <Card key={b.id} sx={{m: 2, p: 2}}>
|
||||
<Stack direction='row' justifyContent={'space-between'}>
|
||||
<Typography>{b.name}</Typography>
|
||||
<Box>
|
||||
<IconButton onClick={() => {
|
||||
setOldId(b.id)
|
||||
setOpen(true);
|
||||
}}>
|
||||
<ContentCopyIcon/>
|
||||
</IconButton>
|
||||
{b.active && <IconButton disabled>
|
||||
<PowerIcon/>
|
||||
</IconButton>}
|
||||
{!b.active && <>
|
||||
<IconButton
|
||||
onClick={() => barClient.deleteBar(b, bars, createError, createSuccess, setBars)}>
|
||||
<DeleteIcon/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => barClient.changeBar(b.id, bars, createWarning, createSuccess, createError, setBars)}>
|
||||
<ElectricalServicesIcon/>
|
||||
</IconButton>
|
||||
</>}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
})}
|
||||
</Paper>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
11
front/src/app/pages/auth/sign-in/loginPage.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import {GuestGuard} from "../../../../components/auth/guest-guard";
|
||||
import {SignInForm} from "../../../../components/auth/sign-in-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<SignInForm/>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
16
front/src/app/pages/auth/sign-in/telegram-code.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {Loading} from "../../../../components/core/Loading";
|
||||
import {useAuth} from "../../../../hooks/useAuth";
|
||||
import {authClient} from "../../../../lib/clients/AuthClient";
|
||||
|
||||
export function TelegramCode() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const {checkSession} = useAuth();
|
||||
|
||||
authClient.loginByCode(searchParams.get("code"), checkSession)
|
||||
|
||||
return (
|
||||
<Loading loading={true}/>
|
||||
)
|
||||
}
|
||||
90
front/src/app/pages/calc/CalcPage.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import {useEffect, useMemo} from "react";
|
||||
import {useAlert} from "../../../hooks/useAlert";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import {CocktailItemCalc} from "./CocktailItemCalc";
|
||||
import {IngredientCalcCard} from "./IngredientCalcCard";
|
||||
import {cocktailClient} from "../../../lib/clients/CocktailClient";
|
||||
|
||||
export function CalcPage() {
|
||||
const {createError} = useAlert();
|
||||
const [cocktails, setCocktails] = React.useState([]);
|
||||
const [load, setLoad] = React.useState(false);
|
||||
const [cocktailMap, setCocktailMap] = React.useState({});
|
||||
|
||||
const changeHandler = (id, value) => {
|
||||
setCocktailMap((prev) => ({
|
||||
...prev,
|
||||
[id]: value
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
cocktailClient.getCocktailsForCalcPage(load, setLoad, setCocktails, setCocktailMap, createError)
|
||||
// eslint-disable-next-line
|
||||
}, [load]);
|
||||
|
||||
const ingredients = useMemo(() => {
|
||||
let map = {}
|
||||
if (!cocktails) {
|
||||
return [];
|
||||
}
|
||||
cocktails.forEach((c) => {
|
||||
const receipts = c.receipt;
|
||||
const countMeter = cocktailMap[c.id];
|
||||
|
||||
if (!receipts) {
|
||||
return
|
||||
}
|
||||
receipts.forEach((r) => {
|
||||
const ingredient = r.ingredient;
|
||||
const id = ingredient.id;
|
||||
const ingredientCount = r.count;
|
||||
|
||||
const resultCount = ingredientCount * countMeter;
|
||||
|
||||
if (map[id]) {
|
||||
map[id] = {
|
||||
...map[id],
|
||||
count: map[id].count + resultCount
|
||||
}
|
||||
} else {
|
||||
map[id] = {
|
||||
ingredient: ingredient,
|
||||
count: resultCount
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return Object.values(map);
|
||||
},
|
||||
[cocktails, cocktailMap])
|
||||
|
||||
const deleteHandler = (id) => {
|
||||
const state = cocktails.filter((c) => c.id !== id);
|
||||
setCocktails(state);
|
||||
}
|
||||
|
||||
console.log(cocktailMap)
|
||||
|
||||
return (
|
||||
<Box padding={2}>
|
||||
<Typography variant="h4" align="center">Коктейли</Typography>
|
||||
<Stack mt={2}>
|
||||
{cocktails.map((item, i) => (
|
||||
<CocktailItemCalc key={i} cocktail={item} deleteHandler={deleteHandler}
|
||||
changeHandler={changeHandler}/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h4" mt={2} align="center">Ингредиенты</Typography>
|
||||
<Stack mt={2}>
|
||||
{ingredients.map((item, i) => (
|
||||
<IngredientCalcCard key={i} count={item.count} ingredient={item.ingredient}/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
40
front/src/app/pages/calc/CocktailItemCalc.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import {Card} from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import React from "react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {Counter} from "./Counter";
|
||||
|
||||
export function CocktailItemCalc({cocktail, deleteHandler, changeHandler}) {
|
||||
return (
|
||||
<Card sx={{mb: 1, display: 'relative', p: 2}}>
|
||||
<Stack justifyContent={'start'} spacing={2}>
|
||||
<Stack direction='row' justifyContent='start' alignItems='center'>
|
||||
<Box sx={{width: '100px', height: '100px'}}>
|
||||
<img src={cocktail.image} loading='lazy' height={'100px'} width={'100px'} alt={cocktail.id}/>
|
||||
</Box>
|
||||
<Box sx={{width: 'calc(90% - 100px)', pr: 2, ml: 2}}>
|
||||
<Stack>
|
||||
<Typography>{cocktail.name}</Typography>
|
||||
<Typography>{cocktail.volume}</Typography>
|
||||
<Typography>{cocktail.category}</Typography>
|
||||
<Typography>{cocktail.alcoholic}</Typography>
|
||||
<Typography color={'textSecondary'}>{cocktail.components}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack direction='row'>
|
||||
<Stack sx={{width: '5%'}} spacing={1} justifyContent='flex-start'>
|
||||
<IconButton size='small' onClick={() => deleteHandler(cocktail.id)}>
|
||||
<DeleteIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Counter id={cocktail.id} changeHandler={changeHandler}/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
65
front/src/app/pages/calc/Counter.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Box, TextField, Button} from '@mui/material';
|
||||
import {styled} from '@mui/material/styles';
|
||||
|
||||
// Стилизуем контейнер счетчика
|
||||
styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 150px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
//box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
//background-color: #ffffff;
|
||||
`;
|
||||
|
||||
export function Counter({id, changeHandler}) {
|
||||
const [value, setValue] = useState(1);
|
||||
|
||||
const handleChange = (newValue) => {
|
||||
setValue(newValue);
|
||||
changeHandler(id, newValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button onClick={() => {
|
||||
if (value > 0) {
|
||||
setValue(value - 1);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: '20px',
|
||||
height: '55px',
|
||||
borderRadius: '50%',
|
||||
margin: '0 8px',
|
||||
backgroundColor: 'transparent',
|
||||
}}>−</Button>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
if (!isNaN(newValue)) {
|
||||
handleChange(newValue);
|
||||
}
|
||||
}}
|
||||
inputProps={{inputMode: 'numeric'}}
|
||||
sx={{
|
||||
width: '40px',
|
||||
height: '15px',
|
||||
fontSize: '10px',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => handleChange(value + 1)}
|
||||
sx={{
|
||||
width: '20px',
|
||||
height: '55px',
|
||||
borderRadius: '50%',
|
||||
margin: '0 8px',
|
||||
backgroundColor: 'transparent',
|
||||
}}>+</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
21
front/src/app/pages/calc/IngredientCalcCard.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import {Card} from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import React from "react";
|
||||
|
||||
export function IngredientCalcCard({ingredient, count}) {
|
||||
return (
|
||||
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1}}>
|
||||
<Stack direction='row' justifyContent='start' alignItems='center'>
|
||||
<Box sx={{width: '100px', height: '100px'}}>
|
||||
<img src={ingredient.image} loading='lazy' height={'100px'} width={'100px'} alt={ingredient.id}/>
|
||||
</Box>
|
||||
<Box sx={{width: 'calc(90% - 100px)', pr: 2}}>{ingredient.name}</Box>
|
||||
<Stack direction='row'>
|
||||
<Box mr={1} pt={'3px'}>{count}</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
|
||||
}
|
||||
196
front/src/app/pages/cocktails/CocktailsPageContent.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {useAlert} from "../../../hooks/useAlert";
|
||||
import * as React from "react";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import {Cocktail} from "../../../components/cocktails/Cocktail";
|
||||
import {Fab, Skeleton} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import {NoResult} from "../../../components/cocktails/NoResult";
|
||||
import {FilterBlock} from "../../../components/cocktails/FilterBlock";
|
||||
import {CocktailInfoModal} from "../../../components/cocktails/CocktailInfoModal";
|
||||
import {useUser} from "../../../hooks/useUser";
|
||||
import {blue} from "@mui/material/colors";
|
||||
import UpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import {useSelect} from "../../../hooks/useSelect";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import CheckMarks from "../../../components/cocktails/CheckMarks";
|
||||
import {cocktailClient} from "../../../lib/clients/CocktailClient";
|
||||
|
||||
const emptyFilter = {
|
||||
search: "",
|
||||
all: false,
|
||||
hidden: true,
|
||||
onlyFavourite: false,
|
||||
glass: [],
|
||||
category: [],
|
||||
alcohol: [],
|
||||
iCount: [],
|
||||
ingredient: [],
|
||||
inMenu: "",
|
||||
sorting: "Название по возрастанию"
|
||||
}
|
||||
|
||||
const CocktailsPageContent = () => {
|
||||
const {user} = useUser();
|
||||
const {createError, createSuccess} = useAlert();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [filter, setFilter] = useState(emptyFilter)
|
||||
// const [chips, setChips] = useState([])
|
||||
const chips = [];
|
||||
const [page, setPage] = useState(-1);
|
||||
const [load, setLoad] = useState(false);
|
||||
const [isEnd, setIsEnd] = useState(false);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
|
||||
const {selectCocktail, getCocktail, getOpenCocktail} = useSelect();
|
||||
|
||||
const loading = useCallback(() => {
|
||||
const size = Math.floor((window.innerWidth) / 350) * 5;
|
||||
if (load || (!isNew && isEnd)) {
|
||||
return false;
|
||||
}
|
||||
cocktailClient.getMenu(setRows, setIsNew, setPage, setLoad, setIsEnd, isNew, rows, page, size, filter, createError);
|
||||
// eslint-disable-next-line
|
||||
}, [load, isEnd, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const {scrollTop, scrollHeight, clientHeight} = document.documentElement;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
loading();
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
// eslint-disable-next-line
|
||||
}, [loading]);
|
||||
// eslint-disable-next-line
|
||||
useEffect(() => loading(), [filter])
|
||||
|
||||
const renderSkeleton = () => {
|
||||
return Array.from({length: 3}, () => null)
|
||||
.map((v, index) => <Skeleton sx={{m: 2}}
|
||||
key={index}
|
||||
variant="rounded"
|
||||
width={350}
|
||||
height={690}/>);
|
||||
}
|
||||
const handleChangeRating = (row, value) => {
|
||||
const newState = rows.map((r) => {
|
||||
if (row.id === r.id) {
|
||||
let newRating = r.rating;
|
||||
newRating.rating = value
|
||||
return {
|
||||
...r,
|
||||
rating: newRating
|
||||
}
|
||||
}
|
||||
return r;
|
||||
})
|
||||
cocktailClient.changeRating(row.id, newState, value, setRows, createSuccess, createError)
|
||||
|
||||
}
|
||||
const handleFilterChange = (filterName, value) => {
|
||||
const newState = {
|
||||
...filter,
|
||||
[filterName]: value
|
||||
}
|
||||
setFilter(newState)
|
||||
setIsNew(true);
|
||||
setIsEnd(false);
|
||||
setPage(-1);
|
||||
}
|
||||
const handleFavourite = (row) => {
|
||||
const value = !row.rating.favourite;
|
||||
const newState = rows.map((r) => {
|
||||
if (r.id === row.id) {
|
||||
let newRating = r.rating;
|
||||
newRating.favourite = value;
|
||||
return {
|
||||
...r,
|
||||
rating: newRating
|
||||
}
|
||||
}
|
||||
return r;
|
||||
});
|
||||
cocktailClient.changeFavourite(value, row.id, newState, setRows, createSuccess, createError)
|
||||
}
|
||||
const handleFilterClear = () => {
|
||||
setFilter(emptyFilter);
|
||||
setIsNew(true);
|
||||
setIsEnd(false);
|
||||
setPage(-1);
|
||||
}
|
||||
|
||||
const handleSelectCocktail = (row) => selectCocktail(row.id)
|
||||
const deleteHandle = (row) => cocktailClient.deleteCocktail(row.id, rows, setRows, createSuccess, createError)
|
||||
const hideHandler = (id) => {
|
||||
cocktailClient.hiddenCocktail(id)
|
||||
.then(() => {
|
||||
createSuccess("Коктейль скрыт успешно");
|
||||
setRows(rows.filter((r) => r.id !== id))
|
||||
}).catch(() => createError("Ошибка при попытке скрыть коктейль"))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/*<Loading loading={load}/>*/}
|
||||
{/*Модальное окно информации о коктейле*/}
|
||||
<CocktailInfoModal row={getCocktail()} open={getOpenCocktail()}/>
|
||||
{/*Блок фильтров*/}
|
||||
<FilterBlock
|
||||
filter={filter}
|
||||
handleFilterChange={handleFilterChange}
|
||||
handleClearFilter={handleFilterClear}
|
||||
barmen={user.role !== 'USER'}
|
||||
/>
|
||||
|
||||
{
|
||||
(filter.all && filter.iCount === 1) && (
|
||||
<Paper sx={{mt: 1}}>
|
||||
<CheckMarks rows={chips} name={"Выбор ингредиента"} filterName={"ingredient"}
|
||||
filterValue={filter.ingredient}
|
||||
handleChange={handleFilterChange}
|
||||
identity
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
<Box>
|
||||
{/*Основное содержимое*/}
|
||||
<Grid container rowSpacing={2} columnSpacing={{xs: 1, sm: 1, md: 2}} sx={{m: 1}}>
|
||||
{rows.length > 0 && rows.map((row) => {
|
||||
return (
|
||||
<Cocktail key={row.id} row={row} handleFavourite={handleFavourite}
|
||||
handleChangeRating={handleChangeRating}
|
||||
handleSelect={handleSelectCocktail}
|
||||
deleteHandler={deleteHandle}
|
||||
hideHandler={hideHandler}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{load && renderSkeleton()}
|
||||
{rows.length === 0 && (<NoResult/>)}
|
||||
</Grid>
|
||||
</Box>
|
||||
<Fab sx={{
|
||||
alpha: '30%',
|
||||
position: 'sticky',
|
||||
left: 'calc(100% - 16px)',
|
||||
bottom: '16px',
|
||||
color: 'common.white',
|
||||
bgcolor: blue[600],
|
||||
'&:hover': {
|
||||
bgcolor: blue[600],
|
||||
},
|
||||
}}
|
||||
onClick={() => window.window.scrollTo(0, 0)}
|
||||
aria-label='Expand'
|
||||
color='inherit'>
|
||||
<UpIcon/>
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CocktailsPageContent;
|
||||
194
front/src/app/pages/cocktails/EditCocktailPage.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import {Autocomplete} from "@mui/material";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {useAlert} from "../../../hooks/useAlert";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import {EditCocktailReceipt} from "../../../components/cocktails/EditCocktailReceipt";
|
||||
import {SelectEdit} from "../../../components/cocktails/SelectEdit";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {Loading} from "../../../components/core/Loading";
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import {styled} from "@mui/material/styles";
|
||||
import {cocktailClient} from "../../../lib/clients/CocktailClient";
|
||||
import {categoryClient} from "../../../lib/clients/CategoryClient";
|
||||
import {glassClient} from "../../../lib/clients/GlassClient";
|
||||
|
||||
const emptyCocktail = {
|
||||
id: null,
|
||||
name: "",
|
||||
alcoholic: "",
|
||||
category: "",
|
||||
components: "",
|
||||
glass: "",
|
||||
image: "",
|
||||
instructions: "",
|
||||
isAllowed: false,
|
||||
rating: {
|
||||
rating: 0,
|
||||
favourite: false
|
||||
},
|
||||
receipt: [],
|
||||
tags: "",
|
||||
video: ""
|
||||
};
|
||||
const alcohol = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Алкогольный"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Безалкогольный",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Опционально"
|
||||
}
|
||||
]
|
||||
const VisuallyHiddenInput = styled('input')({
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
});
|
||||
|
||||
export function EditCocktailPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {createError, createSuccess, getError} = useAlert();
|
||||
const [cocktails, setCocktails] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [cocktail, setCocktail] = useState(emptyCocktail);
|
||||
|
||||
const [glass, setGlass] = useState([]);
|
||||
const [category, setCategory] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
cocktailClient.getSimpleList(setCocktails, setSelected, setLoading, createError, searchParams.get("id"))
|
||||
categoryClient.getCategoryList(setCategory, createError);
|
||||
glassClient.getGlassList(setGlass, createError)
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line
|
||||
useEffect(() => cocktailClient.getOneCocktail(selected, setCocktail, getError, emptyCocktail), [selected])
|
||||
const saveHandler = () => cocktailClient.saveChangeCocktail(cocktail, createError, createSuccess)
|
||||
const deleteHandle = () => cocktailClient.deleteCocktailFromEdit(setCocktails, setCocktail, createError, cocktails, cocktail, emptyCocktail)
|
||||
|
||||
const changeCocktailValue = (name, value) => {
|
||||
if (name === "tags") {
|
||||
value = value.join(",");
|
||||
}
|
||||
setCocktail((prev) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/*Загрузка*/}
|
||||
<Loading loading={loading}/>
|
||||
{/*Заголовок*/}
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Коктейли</Typography>
|
||||
</Toolbar>
|
||||
{/*Поиск*/}
|
||||
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 2}}>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={cocktails}
|
||||
onChange={(e, v) => {
|
||||
if (!v) {
|
||||
setCocktail(emptyCocktail);
|
||||
setSelected(null)
|
||||
} else {
|
||||
setSelected(v.id)
|
||||
}
|
||||
}}
|
||||
isOptionEqualToValue={(selected, value) => selected.id === value.id}
|
||||
getOptionKey={(selected) => selected.id}
|
||||
getOptionLabel={(selected) => selected.name + (selected.hasError ? " (есть ошибка)" : "")}
|
||||
renderInput={(params) => <TextField {...params} label="Поиск"/>}
|
||||
/>
|
||||
</Paper>
|
||||
{/*Рабочая область*/}
|
||||
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 1, pb: 2}}>
|
||||
<Stack>
|
||||
<Box hidden={cocktail.id === null} ml={1} mb={1}>
|
||||
<Button color='error' onClick={() => deleteHandle()}>Удалить коктейль</Button>
|
||||
</Box>
|
||||
{/*Фото*/}
|
||||
<Box ml={1}>
|
||||
<img src={cocktail.image} alt={""} width={300} height={300} loading={'eager'}/>
|
||||
</Box>
|
||||
{/*Редактирование ссылки на фото*/}
|
||||
<Stack direction='row' pr={2} m={1} display='relative'>
|
||||
<TextField sx={{width: '75%'}}
|
||||
label={"Ссылка на фото"} variant='outlined' multiline
|
||||
value={!cocktail.image ? "" : cocktail.image}
|
||||
onChange={(e) => changeCocktailValue("image", e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
component="label"
|
||||
role={undefined}
|
||||
variant="contained"
|
||||
tabIndex={-1}
|
||||
startIcon={<CloudUploadIcon/>}
|
||||
sx={{width: '10%', fontSize: 40, ml: 1, pr: 1}}
|
||||
>
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
onChange={(event) => cocktailClient.savePhoto(event, changeCocktailValue, getError)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
</Stack>
|
||||
{/*Название*/}
|
||||
<Box m={1}>
|
||||
<TextField sx={{mr: 1, mb: 2, minWidth: 300}}
|
||||
variant="outlined" label={"Название"}
|
||||
value={cocktail.name}
|
||||
onChange={(e) => changeCocktailValue("name", e.target.value)}/>
|
||||
</Box>
|
||||
{/*Категория, посуда, алкогольность, теги*/}
|
||||
<Box mb={2}>
|
||||
<SelectEdit value={cocktail.category} label={"Категория"} width={300} margin={1}
|
||||
array={category}
|
||||
attributeName={"category"} handler={changeCocktailValue}/>
|
||||
<SelectEdit value={cocktail.glass} label={"Посуда"} width={300} margin={1} array={glass}
|
||||
attributeName={"glass"} handler={changeCocktailValue}/>
|
||||
<SelectEdit value={cocktail.alcoholic} label={"Алкогольность"} width={300} margin={1}
|
||||
array={alcohol}
|
||||
attributeName={"alcoholic"} handler={changeCocktailValue}/>
|
||||
</Box>
|
||||
{/*Рецепт*/}
|
||||
<EditCocktailReceipt receipt={cocktail.receipt} handler={changeCocktailValue}/>
|
||||
|
||||
<Box pr={2} ml={1}>
|
||||
<TextField sx={{width: '100%'}}
|
||||
label={"Инструкция"} variant='outlined' multiline
|
||||
value={!cocktail.instructions ? "" : cocktail.instructions}
|
||||
onChange={(e) => changeCocktailValue("instructions", e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Box display={'flex'} justifyContent={'flex-end'}>
|
||||
<Button variant='contained' onClick={() => saveHandler()}>Сохранить</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
7
front/src/app/pages/cocktails/MenuPage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import CocktailsPageContent from "./CocktailsPageContent";
|
||||
|
||||
export function MenuPage() {
|
||||
return (
|
||||
<CocktailsPageContent/>
|
||||
)
|
||||
}
|
||||
142
front/src/app/pages/ingredients/EditIngredientPage.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import {Autocomplete, FormControl, FormControlLabel, InputLabel} from "@mui/material";
|
||||
import {useAlert} from "../../../hooks/useAlert";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Button from "@mui/material/Button";
|
||||
import Select from "@mui/material/Select";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import {ingredientClient} from "../../../lib/clients/IngredientClient";
|
||||
|
||||
const emptyIngredient = {
|
||||
id: null,
|
||||
name: "",
|
||||
enName: "",
|
||||
have: false,
|
||||
image: null,
|
||||
type: "",
|
||||
alcohol: false,
|
||||
abv: null,
|
||||
description: null
|
||||
}
|
||||
|
||||
export function EditIngredientPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [ingredients, setIngredients] = useState([]);
|
||||
const [types, setTypes] = useState([]);
|
||||
const [ingredient, setIngredient] = useState(emptyIngredient)
|
||||
const {createError, createSuccess} = useAlert();
|
||||
useEffect(() => {
|
||||
ingredientClient.allList(searchParams.get("id"), setIngredients, setIngredient, createError)
|
||||
ingredientClient.getType(setTypes)
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
const changeIngredientValue = (name, value) => {
|
||||
setIngredient((prev) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/*Заголовок*/}
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Ингредиенты</Typography>
|
||||
</Toolbar>
|
||||
{/*Поиск*/}
|
||||
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 2}}>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={ingredients}
|
||||
|
||||
defaultChecked={emptyIngredient}
|
||||
onChange={(e, v) => {
|
||||
return !v ? setIngredient(emptyIngredient) : setIngredient(v)
|
||||
}}
|
||||
isOptionEqualToValue={(selected, value) => selected.id === value.id}
|
||||
getOptionKey={(selected) => selected.id}
|
||||
getOptionLabel={(selected) => selected.name}
|
||||
renderInput={(params) => <TextField {...params} label="Ингредиенты"/>}
|
||||
/>
|
||||
</Paper>
|
||||
{/*Форма ингредиента*/}
|
||||
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 1, pb: 2}}>
|
||||
<Stack>
|
||||
<Box display={'flex'} justifyContent={'flex-end'} pr={2}>
|
||||
<FormControlLabel control={
|
||||
<Switch
|
||||
checked={ingredient.have}
|
||||
onChange={() => changeIngredientValue("have", !ingredient.have)}
|
||||
/>}
|
||||
label={"Наличие"} labelPlacement={'start'}/>
|
||||
</Box>
|
||||
<Box>
|
||||
<img src={ingredient.image} alt={""} loading={'eager'}/>
|
||||
</Box>
|
||||
<Box m={1}>
|
||||
<TextField sx={{mr: 1, mb: 2, minWidth: 330}}
|
||||
variant="outlined" label={"Название"}
|
||||
value={ingredient.name}
|
||||
onChange={(e) => changeIngredientValue("name", e.target.value)}/>
|
||||
</Box>
|
||||
|
||||
<Box height={70} mt={1} ml={1}>
|
||||
<FormControlLabel sx={{pt: 1}}
|
||||
control={
|
||||
<Switch
|
||||
checked={ingredient.alcohol}
|
||||
onChange={() => changeIngredientValue("alcohol", !ingredient.alcohol)}
|
||||
/>}
|
||||
label="Алкогольный"/>
|
||||
{ingredient.alcohol && (
|
||||
<TextField sx={{width: 100}}
|
||||
variant='outlined' label='Градус'
|
||||
value={!ingredient.abv ? "" : ingredient.abv}
|
||||
onChange={(e) => changeIngredientValue("abv", e.target.value)}/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box mb={2} ml={1}>
|
||||
<FormControl sx={{width: 330}}>
|
||||
<InputLabel id="select-label">Категория</InputLabel>
|
||||
<Select
|
||||
id="select-label"
|
||||
autoWidth
|
||||
label={"Категория"}
|
||||
value={!ingredient.type ? "" : ingredient.type}
|
||||
onChange={(e) => changeIngredientValue("type", e.target.value)}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{types.map((c) => {
|
||||
return (<MenuItem key={c.id} value={c.name}>{c.name}</MenuItem>)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box pr={2} ml={1}>
|
||||
<TextField sx={{width: '100%'}}
|
||||
label={"Описание"} variant='outlined' multiline
|
||||
onChange={(e) => changeIngredientValue("description", e.target.value)}
|
||||
value={!ingredient.description ? "" : ingredient.description}/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Box display={'flex'} justifyContent={'flex-end'}>
|
||||
<Button variant='contained'
|
||||
onClick={() => ingredientClient.saveIngredient(ingredient, createSuccess, createError)}>Сохранить</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
126
front/src/app/pages/ingredients/IngredientsPage.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import {Fab, FormControl, InputAdornment, InputLabel, OutlinedInput, Tabs} from "@mui/material";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import * as React from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {Loading} from "../../../components/core/Loading";
|
||||
import {useAlert} from "../../../hooks/useAlert";
|
||||
import {IngredientInfoModal} from "../../../components/Ingredients/IngredientInfoModal";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import {a11yProps} from "../../../components/core/tabProps";
|
||||
import {CustomTabPanel} from "../../../components/core/TabPanel";
|
||||
import {IngredientList} from "../../../components/Ingredients/IngredientList";
|
||||
import {blue} from "@mui/material/colors";
|
||||
import UpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||
import {useSelect} from "../../../hooks/useSelect";
|
||||
import {ingredientClient} from "../../../lib/clients/IngredientClient";
|
||||
|
||||
export function IngredientsPage() {
|
||||
const [value, setValue] = React.useState(0);
|
||||
const handleChange = (event, newValue) => setValue(newValue);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [findString, setFindString] = useState("");
|
||||
const [ingredients, setIngredients] = useState([]);
|
||||
const {getIngredient, selectIngredient} = useSelect();
|
||||
const {createError, createSuccess} = useAlert();
|
||||
|
||||
useEffect(() => {
|
||||
ingredientClient.getAllIngredients(setIngredients, setLoading, createError)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const visibleIngredients = useMemo(() => {
|
||||
if (findString.length === 0) {
|
||||
return ingredients;
|
||||
}
|
||||
const reg = new RegExp("(.*?)" + findString + "(.*?)", "i");
|
||||
return ingredients.filter((ingredient) => ingredient.name.match(reg) !== null);
|
||||
}, [findString, ingredients]);
|
||||
const ingredientsToAdd = visibleIngredients.filter((ingredient) => !ingredient.have);
|
||||
const ingredientsInBar = visibleIngredients.filter((ingredient) => ingredient.have);
|
||||
|
||||
const changeHandler = (row, value) => {
|
||||
const newState = ingredients.map((ingredient) => {
|
||||
if (ingredient.id === row.id) {
|
||||
return {
|
||||
...ingredient,
|
||||
have: value
|
||||
}
|
||||
} else {
|
||||
return ingredient;
|
||||
}
|
||||
})
|
||||
ingredientClient.changeIngredientIsHave(row.id, value, newState, setIngredients, createError)
|
||||
}
|
||||
const handleDelete = (id) => {
|
||||
const newState = ingredients.filter((ingredient) => ingredient.id !== id);
|
||||
setIngredients(newState)
|
||||
ingredientClient.deleteIngredientIsHave(id, createSuccess, createError)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/*Заголовок*/}
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Ингредиенты бара</Typography>
|
||||
</Toolbar>
|
||||
{/*Поиск*/}
|
||||
<Paper elevation={6} sx={{my: 2}}>
|
||||
<FormControl sx={{m: 1, width: 'calc(100% - 20px'}}>
|
||||
<InputLabel htmlFor="outlined-adornment-amount">Поиск</InputLabel>
|
||||
<OutlinedInput
|
||||
onChange={(e) => setFindString(e.target.value)}
|
||||
label="With normal TextField"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconButton edge="end">
|
||||
<SearchIcon/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
{/*Рабочее поле ингредиентов*/}
|
||||
<Box>
|
||||
<Tabs value={value} onChange={handleChange} aria-label="basic tabs example">
|
||||
<Tab label="В баре" {...a11yProps(0)} />
|
||||
<Tab label="Список" {...a11yProps(1)} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box>
|
||||
<CustomTabPanel value={value} index={0}>
|
||||
<IngredientList rows={ingredientsInBar} value={false} changeHandler={changeHandler}
|
||||
infoHandler={selectIngredient}/>
|
||||
</CustomTabPanel>
|
||||
<CustomTabPanel value={value} index={1}>
|
||||
<IngredientList rows={ingredientsToAdd} value={true} changeHandler={changeHandler}
|
||||
infoHandler={selectIngredient}/>
|
||||
</CustomTabPanel>
|
||||
</Box>
|
||||
<Fab sx={{
|
||||
alpha: '30%',
|
||||
position: 'sticky',
|
||||
bottom: '16px',
|
||||
color: 'common.white',
|
||||
bgcolor: blue[600],
|
||||
'&:hover': {
|
||||
bgcolor: blue[600],
|
||||
},
|
||||
}}
|
||||
onClick={() => window.window.scrollTo(0, 0)}
|
||||
aria-label='Expand'
|
||||
color='inherit'>
|
||||
<UpIcon/>
|
||||
</Fab>
|
||||
{/*Загрузчик*/}
|
||||
<Loading loading={loading}/>
|
||||
{/*Модальное окно информации об ингредиенте*/}
|
||||
<IngredientInfoModal ingredient={getIngredient()} handleDelete={handleDelete}/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
38
front/src/app/pages/notFound/NotFoundPage.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
|
||||
import {paths} from "../../../path";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<Box component="main" sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', minHeight: '100%' }}>
|
||||
<Stack spacing={3} sx={{ alignItems: 'center', maxWidth: 'md' }}>
|
||||
<Box>
|
||||
<Box
|
||||
component="img"
|
||||
alt="Under development"
|
||||
src="/assets/error-404.png"
|
||||
sx={{ display: 'inline-block', height: 'auto', maxWidth: '100%', width: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ textAlign: 'center' }}>
|
||||
404: Страница не найдена или недоступна
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body1" sx={{ textAlign: 'center' }}>
|
||||
Вы либо выбрали какой-то сомнительный маршрут, либо попали сюда по ошибке. Что бы это ни было, попробуйте воспользоваться навигацией
|
||||
</Typography>
|
||||
<Button
|
||||
// component={'a'}
|
||||
href={paths.home}
|
||||
startIcon={<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />}
|
||||
variant="contained"
|
||||
>
|
||||
На домашнюю страницу
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
43
front/src/components/BarCreateModal.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import * as React from "react";
|
||||
import {useState} from "react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Button from "@mui/material/Button";
|
||||
import TextField from "@mui/material/TextField";
|
||||
|
||||
export function BarCreateModal({open, setOpen, create, id}) {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<Dialog fullWidth={true}
|
||||
open={open} onClose={() => setOpen(false)}
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
margin: '8px',
|
||||
},
|
||||
'& .MuiPaper-root': {
|
||||
width: 'calc(100% - 16px)',
|
||||
}
|
||||
}}>
|
||||
<DialogTitle>
|
||||
<Typography>Создать список</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField sx={{width: '75%'}}
|
||||
label={<Typography pt={'4px'}>
|
||||
Название списка</Typography>} variant='outlined'
|
||||
value={!value ? "" : value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => {
|
||||
create(id, value);
|
||||
setValue("");
|
||||
}}>Создать</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
39
front/src/components/Ingredients/IngredientAlert.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
|
||||
export function IngredientAlert({open, handleClose, handleDelete, id, handleCloseParent}) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="Предупреждение об удалении"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">
|
||||
{"Вы готовы удалить ингредиент?"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
После удаления ингредиента, удаляться все рецепты и коктейли связанные с ним!
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Отмена</Button>
|
||||
<Button color='error' onClick={() => {
|
||||
handleClose();
|
||||
handleCloseParent();
|
||||
handleDelete(id)
|
||||
}} autoFocus>
|
||||
Удалить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
37
front/src/components/Ingredients/IngredientCard.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import {Card} from "@mui/material";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AddBoxRoundedIcon from '@mui/icons-material/AddBoxRounded';
|
||||
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import {paths} from "../../path";
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
|
||||
export function IngredientCard({row, value, infoHandler, changeHandler}) {
|
||||
return (
|
||||
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1}}>
|
||||
<Stack direction='row' justifyContent='start' alignItems='center'>
|
||||
<Box sx={{width: '100px', height: '100px'}}>
|
||||
<img src={row.image} loading='lazy' height={'100px'} width={'100px'} alt={row.id}/>
|
||||
</Box>
|
||||
<Box sx={{width: 'calc(90% - 100px)', pr: 2}}>{row.name}</Box>
|
||||
<Stack direction='row'>
|
||||
<Box mr={1} pt={'3px'}>{!row.alcohol ? "" : `${row.abv}%`}</Box>
|
||||
<Stack sx={{width: '5%'}} spacing={1} justifyContent='flex-start'>
|
||||
<IconButton size='small' onClick={() => changeHandler(row, value)}>
|
||||
{value ? <AddBoxRoundedIcon/> : <DeleteIcon/>}
|
||||
</IconButton>
|
||||
<IconButton size='small' onClick={() => infoHandler(row)}>
|
||||
<InfoRoundedIcon/>
|
||||
</IconButton>
|
||||
<IconButton size='small' href={`${paths.bar.ingredientEdit}?id=${row.id}`}>
|
||||
<EditIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
85
front/src/components/Ingredients/IngredientInfoModal.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Button from "@mui/material/Button";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import List from "@mui/material/List";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import {useSelect} from "../../hooks/useSelect";
|
||||
import {IngredientAlert} from "./IngredientAlert";
|
||||
import {useUser} from "../../hooks/useUser";
|
||||
import {cocktailClient} from "../../lib/clients/CocktailClient";
|
||||
|
||||
export function IngredientInfoModal({ingredient, handleDelete}) {
|
||||
const {user} = useUser();
|
||||
const [cocktails, setCocktails] = useState([]);
|
||||
const {closeIngredient, getOpenIngredient, selectCocktail} = useSelect();
|
||||
const {createError} = useAlert();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cocktailClient.getCocktailByIngredient(ingredient, setCocktails)
|
||||
.catch(() => createError())
|
||||
// eslint-disable-next-line
|
||||
}, [ingredient]);
|
||||
|
||||
if (!ingredient) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dialog fullWidth={true} maxWidth="350px" open={getOpenIngredient()} onClose={closeIngredient}
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
margin: '8px',
|
||||
},
|
||||
'& .MuiPaper-root': {
|
||||
width: 'calc(100% - 16px)',
|
||||
}
|
||||
}}>
|
||||
<DialogTitle>{ingredient.name}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{justifyContent: "center"}}>
|
||||
<img src={ingredient.image} alt={ingredient.name} loading={"eager"} width={"300"}/>
|
||||
{ingredient.alcohol && (<Typography>{`Крепость ${ingredient.abv}`}</Typography>)}
|
||||
<Typography>{ingredient.description}</Typography>
|
||||
</Stack>
|
||||
{cocktails.length > 0 && (
|
||||
<>
|
||||
<Typography sx={{mt: 2}}>Коктейли:</Typography>
|
||||
<List>
|
||||
{cocktails.map((c) => {
|
||||
return (
|
||||
<ListItem key={c.id} onClick={() => {
|
||||
selectCocktail(c.id)
|
||||
closeIngredient();
|
||||
}}>
|
||||
<Stack direction={'row'}>
|
||||
<img src={c.image} alt={c.name} loading={"eager"} width={"50"}/>
|
||||
<Typography sx={{mx: 1}}>{c.name}</Typography>
|
||||
{c.rating.rating > 0 && <Typography> {`${c.rating.rating}/5`}</Typography>}
|
||||
</Stack>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{user.role !== 'USER' && <Button onClick={() => setOpen(true)}>Удалить</Button>}
|
||||
<Button onClick={closeIngredient}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
<IngredientAlert handleDelete={handleDelete} handleClose={handleClose} open={open} id={ingredient.id}
|
||||
handleCloseParent={closeIngredient}/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
55
front/src/components/Ingredients/IngredientList.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import {IngredientCard} from "./IngredientCard";
|
||||
import {useMemo, useState} from "react";
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import {Accordion, AccordionDetails, AccordionSummary} from "@mui/material";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {getComparator} from "../core/getComparator";
|
||||
import {groupByForLoop} from "../core/groupByForLoop";
|
||||
|
||||
export function IngredientList({rows, value, infoHandler, changeHandler}) {
|
||||
const visibleRows = useMemo(() => {
|
||||
let res = [];
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = groupByForLoop(rows, "type")
|
||||
if (!group || group.size === 0) {
|
||||
return null;
|
||||
}
|
||||
const keys = Array.from(group.keys());
|
||||
keys.sort(getComparator("asc"))
|
||||
.forEach((key) => {
|
||||
const list = group.get(key)
|
||||
res.push(
|
||||
<Accordion key={key}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon/>}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
>
|
||||
<Typography component="span">{key !== "null" ? key : "Без категории"}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{list.sort(getComparator("asc", "name"))
|
||||
.map((row) => {
|
||||
return (
|
||||
<IngredientCard row={row} key={row.id} value={value}
|
||||
changeHandler={changeHandler} infoHandler={infoHandler}/>
|
||||
)
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})
|
||||
return res;
|
||||
// eslint-disable-next-line
|
||||
}, [rows])
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
{visibleRows}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
48
front/src/components/auth/guest-guard.js
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import {useAuth} from "../../hooks/useAuth";
|
||||
import {logger} from "../../lib/DefaultLogger";
|
||||
import {paths} from "../../path";
|
||||
|
||||
export function GuestGuard({ children }) {
|
||||
const { auth, error, isLoading } = useAuth();
|
||||
const [isChecking, setIsChecking] = React.useState(true);
|
||||
|
||||
const checkPermissions = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth) {
|
||||
logger.debug('[GuestGuard]: User is logged in, redirecting to dashboard');
|
||||
window.location.replace(paths.dashboard.overview);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
checkPermissions().catch(() => {
|
||||
// noop
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
|
||||
}, [auth, error, isLoading]);
|
||||
|
||||
if (isChecking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert color="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
122
front/src/components/auth/sign-in-form.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import {useState} from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {paths} from "../../path";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import {red} from "@mui/material/colors";
|
||||
import {useAuth} from "../../hooks/useAuth";
|
||||
import {authClient} from "../../lib/clients/AuthClient";
|
||||
|
||||
const emptyRequest = {
|
||||
byLogin: true,
|
||||
code: "",
|
||||
login: "",
|
||||
password: ""
|
||||
}
|
||||
|
||||
export function SignInForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [request, setRequest] = useState(emptyRequest);
|
||||
const [pass, setPass] = useState(true)
|
||||
const {checkSession} = useAuth();
|
||||
|
||||
const buttonSx = {
|
||||
minWidth: "300px",
|
||||
...(error && {
|
||||
bgcolor: red[500],
|
||||
'&:hover': {
|
||||
bgcolor: red[700],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const handleButtonClick = async () => {
|
||||
await authClient.login(request, setLoading, setError, checkSession)
|
||||
}
|
||||
|
||||
const renderByCode = () => {
|
||||
return (
|
||||
<Stack direction='row' mt={1}>
|
||||
<IconButton href={paths.auth.bot} target="_blank" color='primary'>
|
||||
<TelegramIcon/>
|
||||
</IconButton>
|
||||
|
||||
<TextField value={request.code}
|
||||
onChange={(e) => setRequest((prevState) => ({
|
||||
...prevState,
|
||||
code: e.target.value
|
||||
}))}
|
||||
sx={{minWidth: 300}} id="outlined-basic" label="Код подтверждения"
|
||||
variant="outlined"/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
const renderByLogin = () => {
|
||||
return (
|
||||
<Stack mt={1} spacing={2}>
|
||||
<TextField value={request.login}
|
||||
onChange={(e) => setRequest(prevState => ({
|
||||
...prevState,
|
||||
login: e.target.value
|
||||
}))}
|
||||
sx={{minWidth: 300}} id="loginField" label="Логин"
|
||||
variant="outlined"/>
|
||||
<TextField value={request.password}
|
||||
onChange={(e) => setRequest((prevState) => ({
|
||||
...prevState,
|
||||
password: e.target.value
|
||||
}))}
|
||||
sx={{minWidth: 300}} id="passwordField" label="Пароль" type="password"
|
||||
autoComplete="current-password" variant="outlined"/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={4} sx={{marginBottom: '85%', marginTop: '45%'}}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4">Авторизация</Typography>
|
||||
<Typography variant='body1' component="a" href='#'
|
||||
onClick={() => {
|
||||
setRequest((prevState) => ({
|
||||
...prevState,
|
||||
byLogin: !pass
|
||||
}))
|
||||
setPass(!pass)
|
||||
}}>
|
||||
{pass ? "Вход по телеграмм-коду" : "Вход по логину и паролю"}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{pass ? "Введите логин и пароль"
|
||||
: "Для входа нужно всего лишь сказать об этом моему Telegram-боту, перейди по ссылке и набери \n/start"}
|
||||
</Typography>
|
||||
{pass ? renderByLogin() : renderByCode()}
|
||||
<Box sx={{display: 'flex', alignItems: 'center', ml: '40px', mt: 1}}>
|
||||
<Stack>
|
||||
{error && (
|
||||
<Typography mb={1} color={'error'}>{error}</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={buttonSx}
|
||||
disabled={loading}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress
|
||||
size={25}
|
||||
/>
|
||||
) : "Войти"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
68
front/src/components/cocktails/CheckMarks.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Select from '@mui/material/Select';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 8;
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
|
||||
width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function CheckMarks({rows, name, filterValue, handleChange, filterName, width, nonMulti, nullValue, identity}) {
|
||||
const realValue = !nonMulti ? filterValue.filter((v) => v.length > 0) : filterValue;
|
||||
return (
|
||||
<div>
|
||||
<FormControl sx={{m: 1, width: !width ? 300 : width}}>
|
||||
<InputLabel>{name}</InputLabel>
|
||||
<Select
|
||||
multiple={!nonMulti}
|
||||
value={realValue}
|
||||
onChange={(e) => handleChange(filterName, e.target.value)}
|
||||
input={<OutlinedInput label={name}/>}
|
||||
renderValue={(selected) => !nonMulti ? selected.join(", ") : selected}
|
||||
MenuProps={MenuProps}
|
||||
defaultChecked={nonMulti && rows[0]}
|
||||
variant="filled">
|
||||
{(nonMulti && nullValue) && (
|
||||
<MenuItem value={""}>
|
||||
<em>Не выбрано</em>
|
||||
</MenuItem>
|
||||
)}
|
||||
{rows.map((value) => {
|
||||
if(identity) {
|
||||
return (
|
||||
<MenuItem key={"menuItemIn" + value} value={value}>
|
||||
{!nonMulti && (
|
||||
<Checkbox
|
||||
checked={realValue.includes(value)}/>
|
||||
)}
|
||||
<ListItemText primary={value}/>
|
||||
</MenuItem>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<MenuItem key={value.id} value={value.name}>
|
||||
{!nonMulti && (
|
||||
<Checkbox
|
||||
checked={realValue.includes(value.name)}/>
|
||||
)}
|
||||
<ListItemText primary={value.name}/>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
front/src/components/cocktails/Cocktail.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import {CardActions, CardContent, CardMedia, Rating} from "@mui/material";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {CocktailItemStyled} from "./CocktailItemStyled";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import FavoriteBorderIcon from '@mui/icons-material/FavoriteTwoTone';
|
||||
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||
import Box from "@mui/material/Box";
|
||||
import {CocktailDescription} from "./CocktailDescription";
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import LocalBarIcon from '@mui/icons-material/LocalBar';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import {paths} from "../../path";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import {useUser} from "../../hooks/useUser";
|
||||
import {cocktailClient} from "../../lib/clients/CocktailClient";
|
||||
|
||||
function renderFavouriteBadge(handleFavourite, row) {
|
||||
const childIcon = row.rating.favourite ? <FavoriteIcon/> : <FavoriteBorderIcon/>;
|
||||
return (
|
||||
<IconButton size={'small'}
|
||||
onClick={() => handleFavourite(row)}>
|
||||
{childIcon}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
function renderRating(handleChangeRating, row) {
|
||||
return (
|
||||
<Rating
|
||||
name="simple-controlled"
|
||||
value={row.rating.rating}
|
||||
onChange={(event, newValue) => handleChangeRating(row, newValue)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Cocktail({row, handleFavourite, handleChangeRating, handleSelect, deleteHandler, hideHandler}) {
|
||||
const {createError, createSuccess} = useAlert();
|
||||
const {user} = useUser();
|
||||
return (
|
||||
<Grid item sx={{pr: 2}}>
|
||||
<CocktailItemStyled>
|
||||
<Box sx={{
|
||||
p: '4px 4px',
|
||||
m: 1,
|
||||
width: '320px',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<CardMedia
|
||||
sx={{
|
||||
loading: "eager",
|
||||
borderRadius: 2
|
||||
}}
|
||||
onClick={() => handleSelect(row)}
|
||||
component="img"
|
||||
alt={row.name}
|
||||
height="300"
|
||||
|
||||
image={row.image.includes("thecocktaildb") ? (row.image + "/preview") : row.image}
|
||||
/>
|
||||
<CardActions>
|
||||
<IconButton sx={{m: 0}} size='small'
|
||||
onClick={() => cocktailClient.drinkCocktail(row.id, createSuccess, createError)}>
|
||||
<LocalBarIcon fontSize='small'/>
|
||||
</IconButton>
|
||||
{renderFavouriteBadge(handleFavourite, row)}
|
||||
{renderRating(handleChangeRating, row)}
|
||||
|
||||
{
|
||||
(user.role && user.role !== 'USER') &&
|
||||
<>
|
||||
<IconButton size='small' href={`${paths.bar.cocktailEdit}?id=${row.id}`}>
|
||||
<EditIcon fontSize='small'/>
|
||||
</IconButton>
|
||||
<IconButton size='small' onClick={() => hideHandler(row.id)}>
|
||||
<VisibilityOffIcon fontSize='small'/>
|
||||
</IconButton>
|
||||
<IconButton size='small' onClick={() => deleteHandler(row)}>
|
||||
<DeleteIcon fontSize='small'/>
|
||||
</IconButton>
|
||||
</>
|
||||
|
||||
}
|
||||
</CardActions>
|
||||
<CardContent sx={{pb: 0, pl: 2, pt: 0}}>
|
||||
<Typography variant="h5" minHeight={'50px'} mt={2}>{row.name} </Typography>
|
||||
<CocktailDescription row={row}/>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</CocktailItemStyled>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
32
front/src/components/cocktails/CocktailDescription.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import List from "@mui/material/List";
|
||||
|
||||
export function CocktailDescription({row}) {
|
||||
return (
|
||||
<List sx={{py: '0px'}}>
|
||||
{row.hasError && (
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText color={'red'}>Имеет ошибку в рецепте или ингредиентах</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText>{"Категория: " + row.category}</ListItemText>
|
||||
</ListItem>
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText>{"Алкоголь: " + row.alcoholic}</ListItemText>
|
||||
</ListItem>
|
||||
{row.volume !== null && (
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText>{"Крепость: ≈" + row.volume}</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText>{"Подача: " + row.glass}</ListItemText>
|
||||
</ListItem>
|
||||
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
|
||||
<ListItemText>{"Состав: " + row.components}</ListItemText>
|
||||
</ListItem>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
124
front/src/components/cocktails/CocktailInfoModal.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {CardMedia} from "@mui/material";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Box from "@mui/material/Box";
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import {IngredientInfoModal} from "../Ingredients/IngredientInfoModal";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import {paths} from "../../path";
|
||||
import {Loading} from "../core/Loading";
|
||||
import {useUser} from "../../hooks/useUser";
|
||||
import {useSelect} from "../../hooks/useSelect";
|
||||
import {cocktailClient} from "../../lib/clients/CocktailClient";
|
||||
import {ingredientClient} from "../../lib/clients/IngredientClient";
|
||||
|
||||
export function CocktailInfoModal({row}) {
|
||||
const {user} = useUser();
|
||||
const {getError, createError, createSuccess} = useAlert();
|
||||
const [cocktail, setCocktail] = useState(null)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const {closeCocktail, selectIngredient, getIngredient, getOpenCocktail} = useSelect();
|
||||
|
||||
// eslint-disable-next-line
|
||||
useEffect(() => cocktailClient.getCocktailForModal(row, setLoading, setCocktail, closeCocktail, getError), [row]);
|
||||
|
||||
if (!row || !cocktail) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dialog fullWidth={true}
|
||||
open={getOpenCocktail()} onClose={closeCocktail}
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
margin: '8px',
|
||||
},
|
||||
'& .MuiPaper-root': {
|
||||
width: 'calc(100% - 16px)',
|
||||
}
|
||||
}}>
|
||||
<IngredientInfoModal ingredient={getIngredient()}/>
|
||||
<Loading loading={loading}/>
|
||||
<DialogTitle>
|
||||
<Stack direction='row' justifyContent={'space-between'}>
|
||||
<Box>{cocktail.name}</Box>
|
||||
|
||||
{cocktail.rating.rating > 0 &&
|
||||
(
|
||||
<Stack ml={3} direction='row'>
|
||||
{`${cocktail.rating.rating}/5`}
|
||||
<StarBorderIcon sx={{pb: "2px"}}/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<CardMedia
|
||||
image={cocktail.image.includes("thecocktaildb") ? (cocktail.image + "/preview") : cocktail.image}
|
||||
sx={{
|
||||
loading: "eager",
|
||||
borderRadius: 2
|
||||
}}
|
||||
component="img"
|
||||
alt={cocktail.name}
|
||||
height="300"
|
||||
/>
|
||||
<Box mt={1}>
|
||||
<Typography>Ингредиенты:</Typography>
|
||||
<Paper sx={{p: 1}} elevation={3}>
|
||||
<Stack>
|
||||
{cocktail.receipt.map((r) => {
|
||||
const hasError = r.count === null || r.unit === null;
|
||||
const measure = hasError ? r.measure : (r.count + " " + r.unit.name)
|
||||
return (
|
||||
<Stack key={r.ingredient.id} direction='row' justifyContent={'space-between'}
|
||||
mt={1}>
|
||||
<Stack direction='row'>
|
||||
{(user.role && user.role !== "USER") && (
|
||||
<IconButton size="small" sx={{pb: "2px"}}
|
||||
onClick={() => ingredientClient.changeIngredientInBar(r.ingredient, cocktail, setCocktail, createSuccess, createError)}>
|
||||
{r.ingredient.isHave
|
||||
? (<DeleteIcon fontSize="small"/>)
|
||||
: (<ShoppingCartIcon fontSize="small"/>)
|
||||
}
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography
|
||||
onClick={() => ingredientClient.findOne(r.ingredient.id, selectIngredient, createError)}>{r.ingredient.name}</Typography>
|
||||
</Stack>
|
||||
<Typography color={hasError && 'red'}>{measure}</Typography>
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography mt={2}>Инструкция:</Typography>
|
||||
<Paper sx={{p: 1}} elevation={3}>
|
||||
<Box>
|
||||
{cocktail.instructions}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{(user.role && user.role.includes("ADMIN")) && (
|
||||
<Button href={`${paths.bar.cocktailEdit}?id=${cocktail.id}`}>Редактировать</Button>
|
||||
)}
|
||||
<Button onClick={closeCocktail}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
12
front/src/components/cocktails/CocktailItemStyled.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {styled} from "@mui/material/styles";
|
||||
import {Card} from "@mui/material";
|
||||
|
||||
export const CocktailItemStyled = styled(Card)(({theme}) => ({
|
||||
backgroundColor: '#fff',
|
||||
...theme.typography.body2,
|
||||
padding: theme.spacing(1),
|
||||
color: theme.palette.text.secondary,
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: '#1A2027',
|
||||
})
|
||||
}));
|
||||
44
front/src/components/cocktails/CocktailListCard.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import {Card, FormControlLabel} from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
|
||||
import {paths} from "../../path";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import React from "react";
|
||||
import Switch from "@mui/material/Switch";
|
||||
|
||||
export function CocktailListCard({row, changeHandler, infoHandler}) {
|
||||
return (
|
||||
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1, borderRadius: '15px'}}>
|
||||
<Stack direction='row' justifyContent='start' alignItems='start'>
|
||||
<Box sx={{width: '100px', height: '100px', ml: 1}}>
|
||||
<img src={row.image} loading='eager' height={'100px'} width={'100px'} alt={row.id}
|
||||
style={{borderRadius: '20%'}}/>
|
||||
</Box>
|
||||
<Stack sx={{width: 'calc(95% - 100px)', pr: 2, ml: 1}}>
|
||||
<Box>{row.name}</Box>
|
||||
<FormControlLabel sx={{mt: 5, pr: 2}}
|
||||
onClick={() => changeHandler(row, !row.inMenu)}
|
||||
value="bottom"
|
||||
control={
|
||||
<Switch color="primary" checked={row.inMenu}/>
|
||||
}
|
||||
label="В меню"
|
||||
labelPlacement="start"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction='row'>
|
||||
<Stack sx={{width: '5%', mt: 2}} spacing={1} justifyContent='flex-start'>
|
||||
<IconButton size='small' onClick={() => infoHandler(row)}>
|
||||
<InfoRoundedIcon/>
|
||||
</IconButton>
|
||||
<IconButton size='small' href={`${paths.bar.cocktailEdit}?id=${row.id}`}>
|
||||
<EditIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
76
front/src/components/cocktails/CocktailsList.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import {useMemo, useState} from "react";
|
||||
import {getComparator} from "../core/getComparator";
|
||||
import {Accordion, AccordionDetails, AccordionSummary} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import {CocktailListCard} from "./CocktailListCard";
|
||||
import {groupByForLoop} from "../core/groupByForLoop";
|
||||
|
||||
export function CocktailsList({rows, grouping, changeHandler, infoHandler}) {
|
||||
const [size, setSize] = useState(20);
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.innerHeight + window.scrollY >= (document.documentElement.scrollHeight - 100)) {
|
||||
if (!grouping) {
|
||||
setSize(size + 10)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
let res = [];
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!grouping) {
|
||||
return rows
|
||||
.sort(getComparator("asc", "name"))
|
||||
.slice(0, size)
|
||||
.map((row) => {
|
||||
return (
|
||||
<CocktailListCard row={row} key={row.id}
|
||||
changeHandler={changeHandler} infoHandler={infoHandler}/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const group = groupByForLoop(rows, "category")
|
||||
if (!group || group.size === 0) {
|
||||
return null;
|
||||
}
|
||||
Array.from(group.keys())
|
||||
.sort(getComparator())
|
||||
.map((key) => {
|
||||
const list = group.get(key);
|
||||
res.push(
|
||||
<Accordion key={key} sx={{borderRadius: '5px', mb: 1}}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon/>}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
>
|
||||
<Typography component="span">{key}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{p: 1}}>
|
||||
{list.sort(getComparator("asc", "name"))
|
||||
.map((row) => {
|
||||
return (
|
||||
<CocktailListCard row={row} key={row.id}
|
||||
changeHandler={changeHandler} infoHandler={infoHandler}/>
|
||||
)
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})
|
||||
return res;
|
||||
// eslint-disable-next-line
|
||||
}, [size, rows])
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
{visibleRows}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
162
front/src/components/cocktails/EditCocktailReceipt.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import {Card} from "@mui/material";
|
||||
import {SelectEdit} from "./SelectEdit";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import {ingredientClient} from "../../lib/clients/IngredientClient";
|
||||
|
||||
export function EditCocktailReceipt({receipt, handler}) {
|
||||
const {createError} = useAlert()
|
||||
const [ingredients, setIngredients] = useState([]);
|
||||
const [units, setUnits] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
ingredientClient.findAll(setIngredients, createError)
|
||||
ingredientClient.findUnit(setUnits, createError)
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
const selectHandler = (name, value) => {
|
||||
const ing = ingredients.find((i) => i.name === value)
|
||||
const newState = receipt.map((r, i) => {
|
||||
if (i !== name) {
|
||||
return r;
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
ingredient: {
|
||||
id: ing.id,
|
||||
isHave: ing.have,
|
||||
name: ing.name
|
||||
},
|
||||
measure: r.measure
|
||||
}
|
||||
})
|
||||
handler("receipt", newState);
|
||||
checkAllowed(newState);
|
||||
}
|
||||
const unitHandler = (name, value) => {
|
||||
const ing = units.find((i) => i.name === value)
|
||||
const newState = receipt.map((r, i) => {
|
||||
if (i !== name) {
|
||||
return r;
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
ingredient: r.ingredient,
|
||||
unit: ing,
|
||||
count: r.count,
|
||||
measure: r.measure
|
||||
}
|
||||
})
|
||||
handler("receipt", newState);
|
||||
checkAllowed(newState);
|
||||
}
|
||||
const removeHandler = (index) => {
|
||||
const arr = receipt.filter((r, i) => i !== index)
|
||||
handler("receipt", arr)
|
||||
checkAllowed(arr)
|
||||
}
|
||||
const addHandler = () => {
|
||||
const oldState = receipt;
|
||||
oldState.push({
|
||||
id: null,
|
||||
ingredient: {
|
||||
id: null,
|
||||
isHave: false,
|
||||
name: ""
|
||||
},
|
||||
measure: ""
|
||||
});
|
||||
handler("receipt", oldState);
|
||||
checkAllowed(oldState);
|
||||
}
|
||||
const checkAllowed = (state) => {
|
||||
handler("isAllowed", !state.map((r) => r.ingredient.isHave).includes(false))
|
||||
}
|
||||
const measureHandler = (index, value) => {
|
||||
const newState = receipt.map((r, i) => {
|
||||
if (index !== i) {
|
||||
return r
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
measure: value
|
||||
}
|
||||
})
|
||||
handler("receipt", newState)
|
||||
}
|
||||
const countHandler = (index, value) => {
|
||||
const newState = receipt.map((r, i) => {
|
||||
if (index !== i) {
|
||||
return r
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
count: value
|
||||
}
|
||||
})
|
||||
handler("receipt", newState)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mb={2}>
|
||||
{/*Заголовок*/}
|
||||
<Stack direction='row' justifyContent={'space-between'} sx={{mr: 1}}>
|
||||
<Typography ml={1} mt={1}>Рецепт</Typography>
|
||||
<IconButton onClick={() => addHandler()}>
|
||||
<AddIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
{/*Рецепт*/}
|
||||
<Stack sx={{mr: 1}}>
|
||||
{receipt.map((r, i) => {
|
||||
return (
|
||||
<Card key={i} sx={{ml: 0, mb: 1}}>
|
||||
<Stack>
|
||||
<Stack direction='row'>
|
||||
<SelectEdit width={'calc(65% - 28px)'} array={ingredients} value={r.ingredient.name}
|
||||
handler={selectHandler} label={"Ингредиент"}
|
||||
margin={1} attributeName={i}
|
||||
/>
|
||||
<TextField sx={{width: 'calc(35% - 28px)', mt: 1}}
|
||||
label={"Кол-во"}
|
||||
variant="outlined"
|
||||
disabled
|
||||
value={r.measure}
|
||||
onChange={(e) => measureHandler(i, e.target.value)}
|
||||
/>
|
||||
<IconButton sx={{mt: 2}}
|
||||
onClick={() => removeHandler(i)}
|
||||
>
|
||||
<DeleteForeverIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction='row' ml={1} mb={1}>
|
||||
<TextField sx={{width: 'calc(35% - 28px)', mt: 1}}
|
||||
label={"Кол-во"}
|
||||
variant="outlined"
|
||||
value={r.count}
|
||||
onChange={(e) => countHandler(i, e.target.value)}
|
||||
/>
|
||||
<SelectEdit width={'calc(65% - 28px)'} array={units}
|
||||
value={!r.unit ? null : r.unit.name}
|
||||
handler={unitHandler} label={"Ед."}
|
||||
margin={1} attributeName={i}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
105
front/src/components/cocktails/FilterBlock.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import {Card, FormControl, FormControlLabel, InputAdornment, InputLabel, OutlinedInput} from "@mui/material";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import CheckMarks from "./CheckMarks";
|
||||
import Button from "@mui/material/Button";
|
||||
import * as React from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useAlert} from "../../hooks/useAlert";
|
||||
import {categoryClient} from "../../lib/clients/CategoryClient";
|
||||
import {glassClient} from "../../lib/clients/GlassClient";
|
||||
|
||||
export function FilterBlock({filter, handleFilterChange, handleClearFilter, barmen}) {
|
||||
const {createError} = useAlert();
|
||||
const [glass, setGlass] = useState([]);
|
||||
const [category, setCategory] = useState([]);
|
||||
const alcohol = ['Алкогольный', 'Безалкогольный'];
|
||||
const ingredientCount = [1, 2, 3, 4, 5];
|
||||
const sort = ['Название по возрастанию', 'Название по убыванию'];
|
||||
|
||||
useEffect(() => {
|
||||
categoryClient.getCategoryList(setCategory, createError)
|
||||
glassClient.getGlassList(setGlass, createError)
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/*Строка поиска*/}
|
||||
<FormControl sx={{m: 1, width: '300px'}}>
|
||||
<InputLabel htmlFor="outlined-adornment-amount">Поиск</InputLabel>
|
||||
<OutlinedInput
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
label="With normal TextField"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<IconButton edge="end">
|
||||
<SearchIcon/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{/*Кнопка открытия фильтров*/}
|
||||
<Tooltip title="Filter list">
|
||||
<IconButton onClick={() => handleFilterChange("hidden", !filter.hidden)}>
|
||||
<FilterListIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/*Блок сортировки*/}
|
||||
<Box hidden={filter.hidden}>
|
||||
<Grid container>
|
||||
{/*Фильтр по алкогольности*/}
|
||||
<CheckMarks rows={sort} name={"Сортировать по..."} handleChange={handleFilterChange}
|
||||
filterValue={filter.sorting} filterName={"sorting"} nonMulti identity/>
|
||||
</Grid>
|
||||
</Box>
|
||||
{/*Блок фильтров*/}
|
||||
<Box hidden={filter.hidden}>
|
||||
<Grid container>
|
||||
{/*Фильтр по избранным*/}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch inputProps={{'aria-label': 'controlled'}}
|
||||
onChange={() => handleFilterChange("onlyFavourite", !filter.onlyFavourite)}
|
||||
/>
|
||||
}
|
||||
label="Только избранные"
|
||||
sx={{ml: 1}}
|
||||
/>
|
||||
{/*Фильтр по избранным*/}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch inputProps={{'aria-label': 'controlled'}}
|
||||
onChange={() => handleFilterChange("all", !filter.all)}
|
||||
/>
|
||||
}
|
||||
label={!filter.all ? "Полный список" : "Только доступные"}
|
||||
sx={{ml: 1}}
|
||||
/>
|
||||
{/*Фильтр по алкогольности*/}
|
||||
<CheckMarks rows={alcohol} name={"Алкогольность"} handleChange={handleFilterChange}
|
||||
filterValue={filter.alcohol} filterName={"alcohol"} identity/>
|
||||
{/*Фильтр по категории*/}
|
||||
{category.length > 0 && (
|
||||
<CheckMarks rows={category} name={"Категории"} filterValue={filter.category}
|
||||
filterName={"category"} handleChange={handleFilterChange}/>)}
|
||||
{/*Фильтр по посуде*/}
|
||||
{glass.length > 0 && (<CheckMarks rows={glass} name={"Подача"} handleChange={handleFilterChange}
|
||||
filterValue={filter.glass} filterName={"glass"}/>)}
|
||||
{/*Фильтр по нехватке ингредиентов*/}
|
||||
{(barmen && filter.all) && (<CheckMarks rows={ingredientCount} name={"Не хватает ингредиентов"}
|
||||
handleChange={handleFilterChange}
|
||||
nonMulti nullValue identity
|
||||
filterValue={filter.iCount} filterName={"iCount"}/>)}
|
||||
<Button onClick={() => handleClearFilter()}>Сбросить</Button>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
17
front/src/components/cocktails/NoResult.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {Stack} from "@mui/material";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {CocktailItemStyled} from "./CocktailItemStyled";
|
||||
|
||||
export function NoResult({load}) {
|
||||
return (
|
||||
<Grid item lg={4} md={6} sm={12} xl={3} hidden={!load}>
|
||||
<CocktailItemStyled>
|
||||
<Stack align="center" sx={{width: "350px"}}>
|
||||
<Typography variant="h5" minHeight={'50px'} mt={2}>Нет результатов</Typography>
|
||||
<Typography>Попробуйте заглянуть позднее</Typography>
|
||||
</Stack>
|
||||
</CocktailItemStyled>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
26
front/src/components/cocktails/SelectEdit.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import {FormControl, InputLabel} from "@mui/material";
|
||||
import Select from "@mui/material/Select";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import * as React from "react";
|
||||
|
||||
export function SelectEdit({label, value, array, handler, attributeName, width, margin}) {
|
||||
|
||||
return (
|
||||
<FormControl sx={{width: width, m: margin}}>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
autoWidth
|
||||
label={label}
|
||||
value={!value ? "" : value}
|
||||
onChange={(e) => handler(attributeName, e.target.value)}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{array.map((c) => {
|
||||
return (<MenuItem key={c.id} value={c.name}>{c.name}</MenuItem>)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
27
front/src/components/cocktails/sortingList.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export const sortList = [
|
||||
{
|
||||
id: "name|asc",
|
||||
name: "Название по возрастанию"
|
||||
},
|
||||
{
|
||||
id: "name|desc",
|
||||
name: "Название по убыванию"
|
||||
},
|
||||
// todo: добавить сортировки в беке
|
||||
// {
|
||||
// id: "rating.rating|desc",
|
||||
// name: "Сначала с оценкой"
|
||||
// },
|
||||
// {
|
||||
// id: "rating.rating|asc",
|
||||
// name: "Сначала без оценки"
|
||||
// },
|
||||
// {
|
||||
// id: "rating.favourite|desc",
|
||||
// name: "Сначала избранные"
|
||||
// },
|
||||
// {
|
||||
// id: "rating.favourite|asc",
|
||||
// name: "Сначала не избранные"
|
||||
// }
|
||||
]
|
||||
12
front/src/components/core/Loading.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Backdrop, CircularProgress} from "@mui/material";
|
||||
|
||||
export function Loading({loading}) {
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1}}
|
||||
open={loading}
|
||||
>
|
||||
<CircularProgress color="inherit"/>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
10
front/src/components/core/LocalizationProvider.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {AdapterDayjs} from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import {LocalizationProvider as Provider} from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import * as React from 'react';
|
||||
import 'dayjs/locale/ru'
|
||||
|
||||
export function LocalizationProvider({children}) {
|
||||
return (
|
||||
<Provider dateAdapter={AdapterDayjs} adapterLocale="ru-Ru">{children}</Provider>
|
||||
);
|
||||
}
|
||||
38
front/src/components/core/Logo.js
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import {NoSsr} from "./NoSsr";
|
||||
|
||||
const HEIGHT = 60;
|
||||
const WIDTH = 60;
|
||||
|
||||
export function Logo({ color = 'dark', emblem, height = HEIGHT, width = WIDTH }) {
|
||||
let url;
|
||||
|
||||
if (emblem) {
|
||||
url = color === 'light' ? '/assets/logo-emblem.svg' : '/assets/logo-emblem--dark.svg';
|
||||
} else {
|
||||
url = color === 'light' ? '/assets/logo.svg' : '/assets/logo--dark.svg';
|
||||
}
|
||||
|
||||
return <Box alt="logo" component="img" height={height} src={url} width={width} />;
|
||||
}
|
||||
|
||||
export function DynamicLogo({
|
||||
colorDark = 'light',
|
||||
colorLight = 'dark',
|
||||
height = HEIGHT,
|
||||
width = WIDTH,
|
||||
...props
|
||||
}) {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const color = colorScheme === 'dark' ? colorDark : colorLight;
|
||||
|
||||
return (
|
||||
<NoSsr fallback={<Box sx={{ height: `${height}px`, width: `${width}px` }} />}>
|
||||
<Logo color={color} height={height} width={width} {...props} />
|
||||
</NoSsr>
|
||||
);
|
||||
}
|
||||
25
front/src/components/core/NoSsr.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
|
||||
|
||||
export function NoSsr(props) {
|
||||
const {children, defer = false, fallback = null} = props;
|
||||
const [mountedState, setMountedState] = React.useState(false);
|
||||
|
||||
useEnhancedEffect(() => {
|
||||
if (!defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mountedState ? children : fallback}
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
front/src/components/core/TabPanel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import PropTypes from "prop-types";
|
||||
import * as React from "react";
|
||||
|
||||
export function CustomTabPanel(props) {
|
||||
const {children, value, index, ...other} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CustomTabPanel.propTypes = {
|
||||
children: PropTypes.node,
|
||||
index: PropTypes.number.isRequired,
|
||||
value: PropTypes.number.isRequired,
|
||||
};
|
||||
81
front/src/components/core/ThemeSwitch.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import {styled, useColorScheme} from "@mui/material/styles";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const {mode, setMode} = useColorScheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
// backgroundColor: 'var(--mui-palette-neutral-950)',
|
||||
border: '1px solid var(--mui-palette-neutral-700)',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
p: '4px 12px',
|
||||
}}
|
||||
>
|
||||
<StyledSwitch
|
||||
checked={mode === 'dark'}
|
||||
onChange={(e) => setMode(e.target.checked ? 'dark' : 'light')}
|
||||
inputProps={{'aria-label': 'controlled'}}
|
||||
/>
|
||||
<Box sx={{flex: '1 1 auto'}}>
|
||||
<Typography color="inherit" variant="subtitle1">
|
||||
{(mode === 'dark' ? "Темная " : "Светлая ") + "тема"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledSwitch = styled(Switch)(({theme}) => ({
|
||||
width: 62,
|
||||
height: 34,
|
||||
padding: 7,
|
||||
'& .MuiSwitch-switchBase': {
|
||||
margin: 1,
|
||||
padding: 0,
|
||||
transform: 'translateX(6px)',
|
||||
'&.Mui-checked': {
|
||||
color: '#fff',
|
||||
transform: 'translateX(22px)',
|
||||
'& .MuiSwitch-thumb:before': {
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff',
|
||||
)}" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>')`,
|
||||
},
|
||||
'& + .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
},
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
|
||||
width: 32,
|
||||
height: 32,
|
||||
'&::before': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
left: 0,
|
||||
top: 0,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
|
||||
'#fff',
|
||||
)}" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>')`,
|
||||
},
|
||||
},
|
||||
'& .MuiSwitch-track': {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
|
||||
borderRadius: 20 / 2,
|
||||
},
|
||||
}));
|
||||
101
front/src/components/core/UserPopover.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {SignOut as SignOutIcon} from '@phosphor-icons/react/dist/ssr/SignOut';
|
||||
import {SignIn as SignInIcon} from '@phosphor-icons/react/dist/ssr/SignIn';
|
||||
import {logger} from "../../lib/DefaultLogger";
|
||||
import {useAuth} from "../../hooks/useAuth";
|
||||
import {authClient} from "../../lib/clients/AuthClient";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import {useUser} from "../../hooks/useUser";
|
||||
import {paths} from "../../path";
|
||||
|
||||
export function UserPopover({anchorEl, onClose, open}) {
|
||||
const {checkSession} = useAuth();
|
||||
const {user, session} = useUser();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const handleSignOut = React.useCallback(async () => {
|
||||
try {
|
||||
const {error} = await authClient.signOut();
|
||||
|
||||
if (error) {
|
||||
logger.error('Sign out error', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the auth state
|
||||
await checkSession?.();
|
||||
|
||||
// UserProvider, for this case, will not refresh the router and we need to do it manually
|
||||
window.location.reload();
|
||||
// After refresh, AuthGuard will handle the redirect
|
||||
} catch (err) {
|
||||
logger.error('Sign out error', err);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [checkSession, location]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{horizontal: 'left', vertical: 'bottom'}}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
slotProps={{paper: {sx: {width: '240px'}}}}
|
||||
>
|
||||
<Box sx={{p: '16px 20px '}}>
|
||||
{userDescriptor(user, session)}
|
||||
</Box>
|
||||
<Divider/>
|
||||
<MenuList disablePadding sx={{p: '8px', '& .MuiMenuItem-root': {borderRadius: 1}}}>
|
||||
{/*<MenuItem component={'a'} href={paths.dashboard.settings} onClick={onClose}>*/}
|
||||
{/* <ListItemIcon>*/}
|
||||
{/* <GearSixIcon fontSize="var(--icon-fontSize-md)"/>*/}
|
||||
{/* </ListItemIcon>*/}
|
||||
{/* Настройки*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/*<MenuItem component={'a'} href={paths.dashboard.account} onClick={onClose}>*/}
|
||||
{/* <ListItemIcon>*/}
|
||||
{/* <UserIcon fontSize="var(--icon-fontSize-md)"/>*/}
|
||||
{/* </ListItemIcon>*/}
|
||||
{/* Профиль*/}
|
||||
{/*</MenuItem>*/}
|
||||
{!user.name ? <MenuItem onClick={() => window.location.replace(paths.auth.signIn)}>
|
||||
<ListItemIcon>
|
||||
<SignInIcon fontSize="var(--icon-fontSize-md)"/>
|
||||
</ListItemIcon>
|
||||
Вход
|
||||
</MenuItem> :
|
||||
<MenuItem onClick={handleSignOut}>
|
||||
<ListItemIcon>
|
||||
<SignOutIcon fontSize="var(--icon-fontSize-md)"/>
|
||||
</ListItemIcon>
|
||||
Выход
|
||||
</MenuItem>
|
||||
}
|
||||
</MenuList>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function userDescriptor(user) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
if (!user.name) {
|
||||
return (<Typography variant="subtitle1">Гость</Typography>);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Typography variant="subtitle1">{user.name + " " + user.lastName}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">{user.id}</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
front/src/components/core/descendingComparator.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export function descendingComparator(a, b, orderBy, lastOrder) {
|
||||
if (getValue(b, orderBy) < getValue(a, orderBy)) {
|
||||
return -1;
|
||||
}
|
||||
if (getValue(b, orderBy) > getValue(a, orderBy)) {
|
||||
return 1;
|
||||
}
|
||||
if (lastOrder && orderBy !== lastOrder) {
|
||||
if (getValue(b, lastOrder) < getValue(a, lastOrder)) {
|
||||
return 1;
|
||||
}
|
||||
if (getValue(b, lastOrder) > getValue(a, lastOrder)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getValue(obj, orderBy) {
|
||||
if (!orderBy) {
|
||||
return obj;
|
||||
}
|
||||
const split = orderBy.split(".")
|
||||
let res = obj[split[0]];
|
||||
for (let i = 1; i < split.length; i++) {
|
||||
res = res[split[i]];
|
||||
}
|
||||
return res;
|
||||
}
|
||||
10
front/src/components/core/getComparator.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import {descendingComparator} from "./descendingComparator";
|
||||
|
||||
export function getComparator(order, orderBy, lastOrder) {
|
||||
if(!order) {
|
||||
order = "asc"
|
||||
}
|
||||
return order === 'desc'
|
||||
? (a, b) => descendingComparator(a, b, orderBy, lastOrder)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy, lastOrder);
|
||||
}
|
||||
14
front/src/components/core/groupByForLoop.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export const groupByForLoop = (arr, prop) => {
|
||||
const grouped = new Map();
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let key = arr[i][prop];
|
||||
if (!key) {
|
||||
key = "Без категории"
|
||||
}
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(arr[i]);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
39
front/src/components/core/navIcons.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import {ChartPie as ChartPieIcon} from '@phosphor-icons/react/dist/ssr/ChartPie';
|
||||
import {GearSix as GearSixIcon} from '@phosphor-icons/react/dist/ssr/GearSix';
|
||||
import {PlugsConnected as PlugsConnectedIcon} from '@phosphor-icons/react/dist/ssr/PlugsConnected';
|
||||
import {User as UserIcon} from '@phosphor-icons/react/dist/ssr/User';
|
||||
import {Users as UsersIcon} from '@phosphor-icons/react/dist/ssr/Users';
|
||||
import {XSquare} from '@phosphor-icons/react/dist/ssr/XSquare';
|
||||
import {
|
||||
Basket,
|
||||
BookOpen,
|
||||
Books,
|
||||
Cheers,
|
||||
CoffeeBean,
|
||||
Coins,
|
||||
Martini,
|
||||
Storefront,
|
||||
Users,
|
||||
Wallet,
|
||||
Calculator
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export const navIcons = {
|
||||
'menu': BookOpen,
|
||||
'list': Books,
|
||||
'storefront': Storefront,
|
||||
'wallet': Wallet,
|
||||
'cocktail': Martini,
|
||||
'visitors': Users,
|
||||
'orders': Cheers,
|
||||
'basket': Basket,
|
||||
'coins': Coins,
|
||||
'ingredients': CoffeeBean,
|
||||
'chart-pie': ChartPieIcon,
|
||||
'gear-six': GearSixIcon,
|
||||
'plugs-connected': PlugsConnectedIcon,
|
||||
'calc': Calculator,
|
||||
'x-square': XSquare,
|
||||
user: UserIcon,
|
||||
users: UsersIcon,
|
||||
}
|
||||
6
front/src/components/core/tabProps.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export function a11yProps(index) {
|
||||
return {
|
||||
id: `simple-tab-${index}`,
|
||||
'aria-controls': `simple-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
55
front/src/components/navigation/MainNav.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import {List as ListIcon} from '@phosphor-icons/react/dist/ssr/List';
|
||||
// import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import {usePopover} from "../../hooks/usePopover";
|
||||
import {MobileNav} from "./MobileNav";
|
||||
import {UserPopover} from "../core/UserPopover";
|
||||
// import Tooltip from "@mui/material/Tooltip";
|
||||
// import {Badge} from "@mui/material";
|
||||
// import {useAlert} from "../../hooks/useAlert";
|
||||
|
||||
export function MainNav() {
|
||||
const [openNav, setOpenNav] = React.useState(false);
|
||||
const userPopover = usePopover();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
borderBottom: '1px solid var(--mui-palette-divider)',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 'var(--mui-zIndex-appBar)',
|
||||
height: '64px'
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={3}
|
||||
sx={{alignItems: 'center', justifyContent: 'space-between', minHeight: '64px', px: 2}}>
|
||||
<Stack sx={{alignItems: 'center'}} direction="row" spacing={3}>
|
||||
<IconButton onClick={() => setOpenNav(true)} sx={{display: {xl: 'none'}}}>
|
||||
<ListIcon/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack sx={{alignItems: 'center'}} direction="row" spacing={2}>
|
||||
<Avatar onClick={userPopover.handleOpen} ref={userPopover.anchorRef} src="/assets/avatar.png"
|
||||
sx={{cursor: 'pointer'}}/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<UserPopover anchorEl={userPopover.anchorRef.current} onClose={userPopover.handleClose}
|
||||
open={userPopover.open}/>
|
||||
<MobileNav
|
||||
onClose={() => {
|
||||
setOpenNav(false);
|
||||
}}
|
||||
open={openNav}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
front/src/components/navigation/MobileNav.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import {NavigationMenu} from "./NavigationMenu";
|
||||
|
||||
//Боковое меню
|
||||
export function MobileNav({open, onClose}) {
|
||||
return (
|
||||
<Drawer
|
||||
PaperProps={{
|
||||
sx: {
|
||||
'--MobileNav-background': 'var(--mui-palette-neutral-950)',
|
||||
'--MobileNav-color': 'var(--mui-palette-common-white)',
|
||||
'--NavItem-color': 'var(--mui-palette-neutral-300)',
|
||||
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
|
||||
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
|
||||
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
|
||||
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
|
||||
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
|
||||
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
|
||||
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
|
||||
bgcolor: 'var(--MobileNav-background)',
|
||||
color: 'var(--MobileNav-color)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '100%',
|
||||
scrollbarWidth: 'none',
|
||||
width: 'var(--MobileNav-width)',
|
||||
zIndex: 'var(--MobileNav-zIndex)',
|
||||
'&::-webkit-scrollbar': {display: 'none'},
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
>
|
||||
<NavigationMenu/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
74
front/src/components/navigation/NavItem.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import {isNavItemActive} from "./isNavItemActive";
|
||||
import {navIcons} from "../core/navIcons";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
export function renderNavItems({items = [], pathname}) {
|
||||
const children = items.reduce((acc, curr) => {
|
||||
const {key, ...item} = curr;
|
||||
acc.push(<NavItem key={key} pathname={pathname} {...item} />);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack key={"stack-NavItem-key"} component="ul" spacing={1} sx={{listStyle: 'none', m: 0, p: 0}}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({disabled, external, href, icon, matcher, pathname, title}) {
|
||||
const active = isNavItemActive({disabled, external, href, matcher, pathname});
|
||||
const Icon = icon ? navIcons[icon] : null;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link to={href} style={{ textDecoration: 'none' }}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
color: 'var(--NavItem-color)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flex: '0 0 auto',
|
||||
gap: 1,
|
||||
p: '6px 16px',
|
||||
position: 'relative',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
...(disabled && {
|
||||
bgcolor: 'var(--NavItem-disabled-background)',
|
||||
color: 'var(--NavItem-disabled-color)',
|
||||
cursor: 'not-allowed',
|
||||
}),
|
||||
...(active && {
|
||||
bgcolor: 'var(--NavItem-active-background)',
|
||||
color: 'var(--NavItem-active-color)'
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box sx={{alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto'}}>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
|
||||
fontSize="var(--icon-fontSize-md)"
|
||||
weight={active ? 'fill' : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{flex: '1 1 auto'}}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px'}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
54
front/src/components/navigation/NavigationMenu.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import {ThemeSwitch} from "../core/ThemeSwitch";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import {renderNavItems} from "./NavItem";
|
||||
import {navItems} from "../../navItems";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import {useUser} from "../../hooks/useUser";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
function renderSpecialItems(items, label, pathname) {
|
||||
return (
|
||||
<Box>
|
||||
{renderNavItems({items: items, pathname: pathname})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const {user} = useUser();
|
||||
const [items, setItems] = useState(null)
|
||||
|
||||
const userChild = navItems.filter((item) => !item.forBarmen && !item.forAdmin)
|
||||
const barmenChild = navItems.filter((item) => item.forBarmen)
|
||||
const adminChild = navItems.filter((item) => item.forAdmin)
|
||||
|
||||
useEffect(() => {
|
||||
const role = !user ? "USER" : Object.keys(user).length === 0 ? "USER" : user.role
|
||||
const newState = (
|
||||
<Box component="nav" sx={{flex: '1 1 auto', p: '12px'}}>
|
||||
{renderNavItems({items: userChild, pathname: pathname})}
|
||||
{role !== "USER" && renderSpecialItems(barmenChild, "Для бармена:", pathname)}
|
||||
{role === "ADMIN" && renderSpecialItems(adminChild, "Для админа", pathname)}
|
||||
</Box>
|
||||
)
|
||||
setItems(newState)
|
||||
// eslint-disable-next-line
|
||||
}, [user, pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*верхняя стопка*/}
|
||||
<Stack spacing={2} sx={{p: 2, height: '63px'}}>
|
||||
<ThemeSwitch/>
|
||||
</Stack>
|
||||
<Divider sx={{borderColor: 'var(--mui-palette-neutral-700)'}}/>
|
||||
{/*меню навигации*/}
|
||||
{items}
|
||||
</>
|
||||
)
|
||||
}
|
||||
37
front/src/components/navigation/SideNav.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {NavigationMenu} from "./NavigationMenu";
|
||||
|
||||
export function SideNav() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'--SideNav-background': 'var(--mui-palette-neutral-950)',
|
||||
'--SideNav-color': 'var(--mui-palette-common-white)',
|
||||
'--NavItem-color': 'var(--mui-palette-neutral-300)',
|
||||
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
|
||||
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
|
||||
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
|
||||
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
|
||||
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
|
||||
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
|
||||
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
|
||||
bgcolor: 'var(--SideNav-background)',
|
||||
color: 'var(--SideNav-color)',
|
||||
display: {xs: 'none', xl: 'flex'},
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
left: 0,
|
||||
maxWidth: '100%',
|
||||
position: 'fixed',
|
||||
scrollbarWidth: 'none',
|
||||
top: 0,
|
||||
width: 'var(--SideNav-width)',
|
||||
zIndex: 'var(--SideNav-zIndex)',
|
||||
'&::-webkit-scrollbar': {display: 'none'},
|
||||
}}
|
||||
>
|
||||
<NavigationMenu/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
front/src/components/navigation/isNavItemActive.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export function isNavItemActive({
|
||||
disabled,
|
||||
external,
|
||||
href,
|
||||
matcher,
|
||||
pathname,
|
||||
}) {
|
||||
if (disabled || !href || external) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (matcher) {
|
||||
if (matcher.type === 'startsWith') {
|
||||
return pathname.startsWith(matcher.href);
|
||||
}
|
||||
|
||||
if (matcher.type === 'equals') {
|
||||
return pathname === matcher.href;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname === href;
|
||||
}
|
||||
49
front/src/context/AuthContext.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {logger} from "../lib/DefaultLogger";
|
||||
import {tokenUtil} from "../lib/clients/TokenUtil";
|
||||
|
||||
export const AuthContext = React.createContext(undefined);
|
||||
|
||||
export function AuthProvider({children}) {
|
||||
const [state, setState] = React.useState({
|
||||
auth: false,
|
||||
error: "",
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
if (!await tokenUtil.checkToken(tokenUtil.getToken())) {
|
||||
setState((prev) => ({...prev, auth: false, error: '', isLoading: false}));
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({...prev, auth: true, error: "", isLoading: false}));
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
setState((prev) => ({...prev, auth: false, error: 'Что-то пошло не так', isLoading: false}));
|
||||
}
|
||||
updater().then();
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSession()
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
const updater = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60));
|
||||
checkSession()
|
||||
.catch((err) => {
|
||||
logger.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{...state}}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export const AuthConsumer = AuthContext.Consumer;
|
||||
61
front/src/context/SelectContext.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const SelectContext = React.createContext(undefined);
|
||||
|
||||
export function SelectProvider({children}) {
|
||||
const [selected, setSelected] = React.useState({
|
||||
cocktail: null,
|
||||
ingredient: null
|
||||
});
|
||||
|
||||
const selectCocktail = (row) => {
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
cocktail: row
|
||||
}))
|
||||
}
|
||||
const getCocktail = () => {
|
||||
return selected.cocktail
|
||||
}
|
||||
const getOpenCocktail = () => {
|
||||
return selected.cocktail !== null;
|
||||
}
|
||||
const closeCocktail = () => {
|
||||
setSelected((prevState) => ({
|
||||
...prevState,
|
||||
cocktail: null,
|
||||
}))
|
||||
}
|
||||
|
||||
const selectIngredient = (row) => {
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
ingredient: row
|
||||
}))
|
||||
}
|
||||
const closeIngredient = () => {
|
||||
setSelected((prevState) => ({
|
||||
...prevState,
|
||||
ingredient: null
|
||||
}))
|
||||
}
|
||||
const getIngredient = () => {
|
||||
return selected.ingredient
|
||||
}
|
||||
const getOpenIngredient = () => {
|
||||
return selected.ingredient !== null
|
||||
}
|
||||
|
||||
return <SelectContext.Provider value={{...selected,
|
||||
selectCocktail,
|
||||
getCocktail,
|
||||
getOpenCocktail,
|
||||
closeCocktail,
|
||||
selectIngredient,
|
||||
closeIngredient,
|
||||
getIngredient,
|
||||
getOpenIngredient
|
||||
}}>{children}</SelectContext.Provider>;
|
||||
}
|
||||
|
||||
export const SelectConsumer = SelectContext.Consumer;
|
||||
55
front/src/context/UserContext.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import {createContext, useCallback, useEffect, useState} from "react";
|
||||
import {logger} from "../lib/DefaultLogger";
|
||||
import {userClient} from "../lib/clients/UserClient";
|
||||
import {tokenUtil} from "../lib/clients/TokenUtil";
|
||||
|
||||
export const UserContext = createContext(undefined);
|
||||
|
||||
export function UserProvider({children}) {
|
||||
const refresh = () => {
|
||||
checkSession()
|
||||
.catch((err) => logger.error(err))
|
||||
}
|
||||
const [state, setState] = useState({
|
||||
user: {},
|
||||
session: {},
|
||||
error: "",
|
||||
isLoading: true,
|
||||
refresh: refresh
|
||||
});
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
setState((prev) => ({...prev, isLoading: true}));
|
||||
if (!tokenUtil.checkToken(tokenUtil.getToken())) {
|
||||
setState((prev) => ({...prev, error: '', isLoading: false, user: {}}));
|
||||
return;
|
||||
}
|
||||
if (Object.keys(state.user).length === 0) {
|
||||
const {data, errorData} = await userClient.getMe();
|
||||
if (errorData) {
|
||||
setState((prev) => ({...prev, error: errorData, isLoading: false, user: {}}));
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({...prev, error: "", isLoading: false, user: data}));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
setState((prev) => ({...prev, error: 'Что-то пошло не так', isLoading: false, user: {}}));
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
checkSession()
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
});
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return <UserContext.Provider value={{...state}}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
export const UserConsumer = UserContext.Consumer;
|
||||
|
||||
36
front/src/hooks/useAlert.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import {useSnackbar} from "notistack";
|
||||
|
||||
export function useAlert() {
|
||||
// variant could be success, error, warning, info, or default
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
function createAlert(message, variant) {
|
||||
const options = {
|
||||
...variant,
|
||||
anchorOrigin: {vertical: 'top', horizontal: 'right'},
|
||||
}
|
||||
enqueueSnackbar(message, options);
|
||||
}
|
||||
|
||||
function notImplement() {
|
||||
createAlert("Данный функционал пока не реализован", {variant: 'warning'});
|
||||
}
|
||||
|
||||
function createWarning(message) {
|
||||
createAlert(message, {variant: 'warning'})
|
||||
}
|
||||
|
||||
function createError(message) {
|
||||
createAlert(message, {variant: "error"});
|
||||
}
|
||||
|
||||
function getError() {
|
||||
createAlert("Ошибка получения данных", {variant: "error"});
|
||||
}
|
||||
|
||||
function createSuccess(message) {
|
||||
createAlert(message, {variant: "success"});
|
||||
}
|
||||
|
||||
return {createAlert, notImplement, createError, getError, createSuccess, createWarning}
|
||||
}
|
||||
14
front/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import {AuthContext} from "../context/AuthContext";
|
||||
|
||||
export function useAuth() {
|
||||
const context = React.useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within a AuthProvider');
|
||||
}
|
||||
|
||||
window.auth = context;
|
||||
|
||||
return context;
|
||||
}
|
||||
20
front/src/hooks/usePopover.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export function usePopover() {
|
||||
const anchorRef = React.useRef(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setOpen((prevState) => !prevState);
|
||||
}, []);
|
||||
|
||||
return { anchorRef, handleClose, handleOpen, handleToggle, open };
|
||||
}
|
||||
14
front/src/hooks/useSelect.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from "react";
|
||||
import {SelectContext} from "../context/SelectContext";
|
||||
|
||||
export function useSelect() {
|
||||
const context = React.useContext(SelectContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSelect must be used within a SelectProvider');
|
||||
}
|
||||
|
||||
window.select = context;
|
||||
|
||||
return context;
|
||||
}
|
||||
47
front/src/hooks/useSelection.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// IMPORTANT: To prevent infinite loop, `keys` argument must be memoized with React.useMemo hook.
|
||||
export function useSelection(keys) {
|
||||
const [selected, setSelected] = React.useState(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelected(new Set());
|
||||
}, [keys]);
|
||||
|
||||
const handleDeselectAll = React.useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleDeselectOne = React.useCallback((key) => {
|
||||
setSelected((prev) => {
|
||||
const copy = new Set(prev);
|
||||
copy.delete(key);
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = React.useCallback(() => {
|
||||
setSelected(new Set(keys));
|
||||
}, [keys]);
|
||||
|
||||
const handleSelectOne = React.useCallback((key) => {
|
||||
setSelected((prev) => {
|
||||
const copy = new Set(prev);
|
||||
copy.add(key);
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedAny = selected.size > 0;
|
||||
const selectedAll = selected.size === keys.length;
|
||||
|
||||
return {
|
||||
deselectAll: handleDeselectAll,
|
||||
deselectOne: handleDeselectOne,
|
||||
selectAll: handleSelectAll,
|
||||
selectOne: handleSelectOne,
|
||||
selected,
|
||||
selectedAny,
|
||||
selectedAll,
|
||||
};
|
||||
}
|
||||
13
front/src/hooks/useUser.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
import {UserContext} from "../context/UserContext";
|
||||
|
||||
export function useUser() {
|
||||
const context = React.useContext(UserContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useUser must be used within a UserProvider');
|
||||
}
|
||||
|
||||
window.user = context;
|
||||
return context;
|
||||
}
|
||||
13
front/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
13
front/src/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './app/App';
|
||||
import './index.css';
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<App/>
|
||||
);
|
||||
4
front/src/lib/DefaultLogger.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import {createLogger} from "./Logger";
|
||||
import {config} from "../Config";
|
||||
|
||||
export const logger = createLogger({ level: config.logLevel });
|
||||
65
front/src/lib/Logger.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/* eslint-disable no-console -- Allow */
|
||||
|
||||
// NOTE: A tracking system such as Sentry should replace the console
|
||||
|
||||
export const LogLevel = {NONE: 'NONE', ERROR: 'ERROR', WARN: 'WARN', DEBUG: 'DEBUG', ALL: 'ALL'};
|
||||
|
||||
const LogLevelNumber = {NONE: 0, ERROR: 1, WARN: 2, DEBUG: 3, ALL: 4};
|
||||
|
||||
export class Logger {
|
||||
prefix;
|
||||
level;
|
||||
showLevel;
|
||||
|
||||
levelNumber;
|
||||
|
||||
constructor({prefix = '', level = LogLevel.ALL, showLevel = true}) {
|
||||
this.prefix = prefix;
|
||||
this.level = level;
|
||||
this.levelNumber = LogLevelNumber[this.level];
|
||||
this.showLevel = showLevel;
|
||||
}
|
||||
|
||||
debug = (...args) => {
|
||||
if (this.canWrite(LogLevel.DEBUG)) {
|
||||
this.write(LogLevel.DEBUG, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
warn = (...args) => {
|
||||
if (this.canWrite(LogLevel.WARN)) {
|
||||
this.write(LogLevel.WARN, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
error = (...args) => {
|
||||
if (this.canWrite(LogLevel.ERROR)) {
|
||||
this.write(LogLevel.ERROR, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
canWrite(level) {
|
||||
return this.levelNumber >= LogLevelNumber[level];
|
||||
}
|
||||
|
||||
write(level, ...args) {
|
||||
let prefix = this.prefix;
|
||||
|
||||
if (this.showLevel) {
|
||||
prefix = `- ${level} ${prefix}`;
|
||||
}
|
||||
|
||||
if (level === LogLevel.ERROR) {
|
||||
console.error(prefix, ...args);
|
||||
} else {
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This can be extended to create context specific logger (Server Action, Router Handler, etc.)
|
||||
// to add context information (IP, User-Agent, timestamp, etc.)
|
||||
|
||||
export function createLogger({prefix, level} = {}) {
|
||||
return new Logger({prefix, level});
|
||||
}
|
||||
45
front/src/lib/clients/AuthClient.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import {api} from "./api";
|
||||
import {requests} from "../../requests";
|
||||
|
||||
class AuthClient {
|
||||
|
||||
async signOut() {
|
||||
localStorage.removeItem("token");
|
||||
return {};
|
||||
}
|
||||
|
||||
async login(request, setLoading, setError, checkSession) {
|
||||
setLoading(true);
|
||||
const response = await api().post(requests.auth.login, request);
|
||||
|
||||
if (response.data.error) {
|
||||
setError(response.data.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem("token", response.data.token);
|
||||
|
||||
await checkSession?.();
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
loginByCode(code, checkSession) {
|
||||
const request = {
|
||||
byLogin: false,
|
||||
code: code
|
||||
}
|
||||
api().post(requests.auth.login, request)
|
||||
.then(async (response) => {
|
||||
if (response.data.error) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("token", response.data.token);
|
||||
await checkSession?.();
|
||||
window.location.reload();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const authClient = new AuthClient();
|
||||