Compare commits
49 Commits
feature/pa
...
master
Author | SHA1 | Date | |
---|---|---|---|
49214fc3e6 | |||
|
88b7806d57 | ||
e0775e8742 | |||
62f8fbf8ea | |||
|
3711761671 | ||
1b9036e8e1 | |||
|
6be3fd12f9 | ||
|
9dbdde6baa | ||
|
c0ad0826d0 | ||
|
d6de1fb959 | ||
|
dffc279b4a | ||
|
e31d89b688 | ||
|
a38409e2d0 | ||
|
1deaebda3d | ||
|
2c8bb5b1ba | ||
|
78377a934e | ||
|
bb7887cb93 | ||
|
7f71e6758c | ||
8e1fe48393 | |||
|
3d827317f0 | ||
|
f3fd265dad | ||
|
092171d27f | ||
|
96b1dafb65 | ||
|
4148a78627 | ||
|
9bf5412b0d | ||
|
ddf3045e92 | ||
|
08e1467bb1 | ||
|
24e2527c1b | ||
|
ac963d658e | ||
|
999cde6726 | ||
|
69223141a4 | ||
|
10257ac7cf | ||
|
5ccf8094c8 | ||
|
53abf5fe4b | ||
|
9d762f4ed2 | ||
|
c13d880baa | ||
|
0ff80a454d | ||
|
9977f5678c | ||
3b90fb7c61 | |||
|
52bda87494 | ||
|
d9902702db | ||
|
bbf3b864e7 | ||
3ead0158ad | |||
|
5879efc978 | ||
|
263be22901 | ||
|
8b2523572d | ||
|
411603e3a1 | ||
|
ff2e9c8b82 | ||
7a74ac5b2a |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
/node_modules/
|
||||
/build/
|
||||
/.build/
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node:15.4.0 as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:mainline-alpine
|
||||
|
||||
COPY --from=build /app/public /var/www
|
||||
COPY --from=build /app/build /var/www
|
||||
COPY ./config/nginx.conf.template /etc/nginx/templates/default.conf.template
|
76
README.md
76
README.md
@ -1,44 +1,44 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
# System Praktyk - Frontend
|
||||
|
||||
## Available Scripts
|
||||
Projekt oparty o [Create React App](https://create-react-app.dev/) z wyciągniętymi najważniejszymi rzeczami w celu
|
||||
minimalizacji zależności.
|
||||
|
||||
In the project directory, you can run:
|
||||
## Skrypty
|
||||
```bash
|
||||
$ yarn server # uruchomienie serwera
|
||||
$ yarn watch # uruchomienie budowania assetów
|
||||
$ yarn build # uruchomienie produkcyjnego builda
|
||||
```
|
||||
|
||||
### `yarn start`
|
||||
## Struktura projektu
|
||||
```
|
||||
/.build - skrypty budujace aplikacje na serwerze
|
||||
/config - konfiguracja
|
||||
/public - pliki publiczne
|
||||
/translations - tłumaczenia
|
||||
/src/api - moduły związane z interfejsowaniem z api
|
||||
/src/data - modele danych
|
||||
/src/forms - formularze i pochodne
|
||||
/src/components - wspólne komponenty
|
||||
/src/hooks - customowe hooki dla reacta
|
||||
/src/pages - podstrony
|
||||
/src/provider - przykładowe dane
|
||||
/src/serialization - serializacja modeli
|
||||
/src/state - zarządzanie stanem
|
||||
/src/management - moduły administracji kursami, struktura analogiczna
|
||||
/src/styles,ui - style
|
||||
/src/utils - pomocne funkcje
|
||||
```
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
## Docker
|
||||
Obraz bazuje na nginxie z linii mainline. Budowanie obrazu:
|
||||
```
|
||||
docker build -f Dockerfile -t system-praktyk-front:latest .
|
||||
```
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
Dostępne są wszystkie zmienne środowiskowe typowe dla nginxa oraz dodatkowo `APP_API_BASE` definiującą bazowy adres pod którym dostępne jest api.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
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.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn 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/).
|
||||
Przykład uruchomienia:
|
||||
```
|
||||
docker run -e APP_API_BASE="https://system-praktyk.stg.kadet.net" -p 80:80 system-praktyk-front
|
||||
```
|
||||
|
18
config/nginx.conf.template
Normal file
18
config/nginx.conf.template
Normal file
@ -0,0 +1,18 @@
|
||||
server {
|
||||
server_name $NGINX_HOST;
|
||||
root /var/www;
|
||||
|
||||
location ~ ^/api/doc$ {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass $APP_API_BASE/api/;
|
||||
proxy_intercept_errors on;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
7
deploy-dev.sh
Normal file
7
deploy-dev.sh
Normal file
@ -0,0 +1,7 @@
|
||||
BASEDIR=$(dirname "$0")
|
||||
|
||||
npx webpack --mode production --progress || exit $?
|
||||
|
||||
rsync -azv $BASEDIR/public/* system-praktyk@kadet.net:~/dev/front
|
||||
rsync -azv $BASEDIR/build/* system-praktyk@kadet.net:~/dev/front
|
||||
|
7
deploy-stg.sh
Normal file
7
deploy-stg.sh
Normal file
@ -0,0 +1,7 @@
|
||||
BASEDIR=$(dirname "$0")
|
||||
|
||||
npx webpack --mode production --progress || exit $?
|
||||
|
||||
rsync -azv $BASEDIR/public/* system-praktyk@kadet.net:~/stg/front
|
||||
rsync -azv $BASEDIR/build/* system-praktyk@kadet.net:~/stg/front
|
||||
|
@ -5,11 +5,14 @@
|
||||
"dependencies": {
|
||||
"@babel/core": "7.9.0",
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@ckeditor/ckeditor5-build-classic": "^23.1.0",
|
||||
"@ckeditor/ckeditor5-react": "^3.0.0",
|
||||
"@date-io/moment": "^1.3.13",
|
||||
"@material-ui/core": "^4.10.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.55",
|
||||
"@material-ui/pickers": "^3.2.10",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9.0",
|
||||
@ -31,14 +34,17 @@
|
||||
"css-loader": "3.4.2",
|
||||
"date-holidays": "^1.5.3",
|
||||
"file-loader": "4.3.0",
|
||||
"filesize": "^6.1.0",
|
||||
"formik": "^2.1.5",
|
||||
"formik-material-ui": "^3.0.0-alpha.0",
|
||||
"html-webpack-plugin": "4.0.0-beta.11",
|
||||
"i18next": "^19.6.0",
|
||||
"i18next-browser-languagedetector": "^5.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"material-table": "^1.69.1",
|
||||
"material-ui-dropzone": "^3.3.0",
|
||||
"mdi-material-ui": "^6.17.0",
|
||||
"moment": "^2.26.0",
|
||||
"moment-timezone": "^0.5.31",
|
||||
"node-sass": "^4.14.1",
|
||||
"optimize-css-assets-webpack-plugin": "5.0.3",
|
||||
"postcss-flexbugs-fixes": "4.1.0",
|
||||
|
53
public/img/pg-logo.svg
Normal file
53
public/img/pg-logo.svg
Normal file
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="prefix__PGLogotyp"
|
||||
viewBox="0 0 143 64"
|
||||
version="1.1">
|
||||
<metadata
|
||||
id="metadata935">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs905">
|
||||
<style
|
||||
id="style903">.prefix__cls-1{fill:#fff}</style>
|
||||
</defs>
|
||||
<path
|
||||
id="prefix__Path_108"
|
||||
d="m 30.446,9.626 -3.339,2.357 1.375,1.866 2.652,-1.964 h 3.045 l 0.982,-7.17 -6.777,-1.277 -0.687,3.732 2.259,0.393 0.295,-1.473 2.357,0.491 -0.393,3.045 z"
|
||||
class="prefix__cls-1"
|
||||
data-name="Path 108" />
|
||||
<path
|
||||
id="prefix__Path_109"
|
||||
d="m 111.866,11.884 2.652,1.964 1.375,-1.866 -3.241,-2.357 h -1.866 l -0.393,-3.045 2.455,-0.491 0.2,1.473 2.259,-0.393 -0.593,-3.731 -6.875,1.277 0.982,7.17 z"
|
||||
class="prefix__cls-1"
|
||||
data-name="Path 109" />
|
||||
<path
|
||||
id="prefix__Path_110"
|
||||
d="m 130.036,44.884 h -2.259 v 2.259 h 7.464 l 2.159,3.045 -5.4,10.705 h -7.857 l 1.67,-3.339 h 3.241 l 3.536,-7.17 h -8.741 V 39.777 l -9.92,-7.464 2.161,-2.848 -1.866,-1.375 -3.339,4.42 v 2.554 l -11.2,2.455 -3.437,-0.982 V 34.571 H 93.991 V 49.4 L 71.4,58.045 48.813,49.4 V 32.214 h -2.259 v 1.964 l -3.634,1.08 h -10.9 v -2.847 l -3.339,-4.42 -1.866,1.375 2.161,2.848 -9.92,7.563 v 10.607 h -8.743 l 3.536,7.17 h 3.241 l 1.67,3.339 H 10.9 L 5.6,50.286 7.761,47.241 h 7.464 V 44.982 H 12.964 V 35.75 l 4.321,-3.241 V 27.795 L 21.8,24.357 20.523,22.589 27.3,17.482 35.259,21.8 v 8.45 L 44.1,31.527 45.473,27.5 h 3.536 V 8.643 h 45.179 v 21.214 h 3.536 l 1.473,4.321 8.741,-4.125 V 21.8 l 7.955,-4.321 6.777,5.107 -1.375,1.768 4.518,3.438 v 4.714 l 4.321,3.241 v 9.134 z m -86.136,-27.5 1.473,-4.42 h 1.179 v 8.054 H 44 l -6.58,4.911 v -5.3 z m -6.384,10.9 z M 46.65,25.338 H 43.9 l -1.277,3.732 -4.616,-0.589 6.875,-5.107 h 1.768 z m 58.732,3.339 -5.009,2.357 L 99.2,27.6 h -2.75 v -4.323 h 1.768 z m -7.759,-15.714 1.473,4.42 6.384,3.143 v 5.3 L 98.9,20.92 h -2.554 v -8.054 h 1.277 z m 39.977,12.573 -1.377,1.864 3.634,2.75 3.143,-4.125 v -1.864 l -6.384,-4.714 -5.009,6.482 v 3.536 l 4.223,3.143 v 4.518 l -7.857,-5.893 v -4.715 l -4.714,-3.536 1.375,-1.768 -6.679,-5.009 2.063,-1.08 5.893,4.42 -1.375,1.768 4.223,3.241 1.375,-1.866 -4.223,-3.143 1.375,-1.768 -5.107,-3.83 v -2.652 l 6.286,4.714 -1.375,1.768 3.438,2.554 1.375,-1.866 -3.437,-2.554 1.375,-1.768 -7.761,-5.795 V 5.795 l 9.036,6.777 -1.375,1.768 2.554,1.964 1.375,-1.866 -2.554,-1.964 1.375,-1.768 -10.214,-7.76 V 0 h -2.259 v 2.455 l -3.634,0.688 0.393,2.259 3.241,-0.589 v 7.857 l -6.187,3.339 -2.848,-2.161 -1.375,1.866 1.964,1.473 -3.732,2.063 -6.973,-3.437 L 99.1,10.705 H 96.25 V 6.384 h -49.6 v 4.321 H 43.8 L 42.032,15.812 35.063,19.25 31.33,17.188 33.294,15.715 31.92,13.848 29.072,16.009 22.884,12.67 V 4.813 L 26.125,5.402 26.518,3.143 22.884,2.456 V 0 h -2.259 v 2.946 l -10.214,7.854 1.375,1.768 -2.554,1.964 1.375,1.868 2.554,-1.964 -1.375,-1.766 9.036,-6.777 v 2.652 l -7.759,5.795 1.375,1.768 -3.438,2.553 1.375,1.866 3.438,-2.554 -1.375,-1.768 6.286,-4.714 v 2.652 l -5.107,3.83 1.375,1.768 -4.223,3.143 1.375,1.67 4.223,-3.241 -1.277,-1.67 5.893,-4.42 2.063,1.08 -6.679,5.009 1.375,1.768 -4.714,3.536 V 31.33 L 7.17,37.223 v -4.518 l 4.223,-3.143 V 25.83 L 6.384,19.348 0,24.063 v 1.866 L 3.143,30.054 6.777,27.304 5.5,25.536 3.634,26.911 2.357,25.143 5.893,22.491 9.036,26.616 v 1.768 l -4.223,3.143 v 6.482 l 1.866,2.455 4.027,-3.045 v 7.563 H 6.58 L 2.946,50.089 9.429,63.25 h 11.785 v -2.357 l -2.75,-5.6 h -3.241 l -1.277,-2.554 h 7.268 V 40.955 L 23.669,39.187 33,43.9 v 6.286 l -2.161,3.144 2.061,4.224 h 3.241 l 1.67,3.339 h -7.856 l -4.027,-8.152 1.964,-2.848 v -5.009 h -4.321 v 2.259 h 2.063 v 2.063 l -2.357,3.339 5.3,10.607 h 11.789 v -2.357 l -2.75,-5.6 h -3.241 l -0.884,-1.768 1.866,-2.652 V 42.33 l -9.527,-4.91 3.929,-2.946 v 2.848 H 43.411 L 46.652,36.34 V 50.679 L 71.5,60.205 96.348,50.678 V 38.795 l 3.438,0.982 13.554,-2.946 V 34.67 l 3.929,2.946 -9.527,4.911 v 8.446 l 1.866,2.652 -0.884,1.768 h -3.241 l -2.75,5.6 v 2.357 h 11.786 l 5.3,-10.607 -2.355,-3.343 v -2.061 h 2.062 V 45.08 h -4.321 v 5.009 l 1.964,2.848 -4.027,8.152 h -7.857 l 1.67,-3.339 H 110.2 L 112.263,53.527 110,50.384 V 44.1 l 9.33,-4.714 2.455,1.768 v 11.784 h 7.268 l -1.277,2.554 h -3.241 l -2.75,5.6 v 2.357 h 11.786 l 6.482,-13.161 -3.634,-5.107 h -4.125 v -7.565 l 4.027,3.045 1.866,-2.455 v -6.483 l -4.223,-3.143 v -1.768 l 3.143,-4.125 3.536,2.652 -1.277,1.67 z"
|
||||
class="prefix__cls-1"
|
||||
data-name="Path 110" />
|
||||
<path
|
||||
id="prefix__Path_111"
|
||||
d="m 63.25,20.822 a 1.2,1.2 0 0 0 -0.884,-0.295 h -2.357 v 3.045 h 2.357 a 1.85,1.85 0 0 0 0.786,-0.2 0.658,0.658 0 0 0 0.393,-0.687 V 21.607 A 0.658,0.658 0 0 0 63.25,20.821"
|
||||
class="prefix__cls-1"
|
||||
data-name="Path 111" />
|
||||
<path
|
||||
id="prefix__Path_112"
|
||||
d="m 86.331,21.215 v 2.063 h -8.349 v -2.063 l -0.393,-3.437 2.063,1.375 -0.491,1.964 h 2.455 l -0.786,-1.964 1.375,-2.259 1.375,2.259 -0.786,1.964 h 2.456 l -0.491,-1.964 2.063,-1.375 z m -0.589,10.116 -2.652,-0.295 0.393,2.652 h -2.554 l 0.393,-2.652 -2.652,0.295 v -2.554 l 2.652,0.393 -0.393,-2.652 h 2.554 l -0.393,2.554 2.652,-0.295 z m 0,10.313 -2.652,-0.393 0.393,2.652 h -2.554 l 0.393,-2.652 -2.652,0.295 v -2.555 l 2.652,0.393 -0.393,-2.554 h 2.554 l -0.393,2.554 2.652,-0.295 v 2.554 z M 72.286,44.102 H 70.813 V 18.759 h 1.473 z M 65.411,22.786 a 3.531,3.531 0 0 1 -0.2,1.08 2.648,2.648 0 0 1 -0.687,0.786 3.072,3.072 0 0 1 -0.982,0.491 4.221,4.221 0 0 1 -1.179,0.2 h -2.354 v 3.536 h -1.768 v -9.727 h 4.129 a 4.93,4.93 0 0 1 1.179,0.2 3.072,3.072 0 0 1 0.982,0.491 1.527,1.527 0 0 1 0.589,0.786 2.127,2.127 0 0 1 0.2,1.179 v 0.982 z m -0.295,18.955 a 3.981,3.981 0 0 1 -0.884,0.491 c -0.295,0.1 -0.589,0.295 -0.884,0.393 a 2.868,2.868 0 0 1 -0.884,0.2 3.028,3.028 0 0 1 -0.982,0.1 5.944,5.944 0 0 1 -1.473,-0.2 2.24,2.24 0 0 1 -1.179,-0.589 3.845,3.845 0 0 1 -0.786,-0.982 3.137,3.137 0 0 1 -0.295,-1.375 v -3.734 a 3.137,3.137 0 0 1 0.295,-1.375 3.845,3.845 0 0 1 0.786,-0.982 3.319,3.319 0 0 1 1.179,-0.589 7.581,7.581 0 0 1 1.473,-0.2 4.758,4.758 0 0 1 1.768,0.295 3.7,3.7 0 0 1 1.473,0.884 l -0.687,1.277 a 7.326,7.326 0 0 0 -1.179,-0.688 3.024,3.024 0 0 0 -1.277,-0.295 2.163,2.163 0 0 0 -0.786,0.1 4.787,4.787 0 0 0 -0.687,0.295 c -0.2,0.1 -0.295,0.295 -0.491,0.491 a 1.42,1.42 0 0 0 -0.2,0.688 v 3.732 a 1.42,1.42 0 0 0 0.2,0.688 1.184,1.184 0 0 0 0.491,0.491 4.788,4.788 0 0 0 0.688,0.295 3.585,3.585 0 0 0 1.67,0 3.489,3.489 0 0 0 0.884,-0.393 V 38.602 H 61.97 v -1.28 h 3.241 v 4.42 z M 51.17,10.902 v 36.83 l 20.33,7.857 20.33,-7.857 v -36.83 z"
|
||||
class="prefix__cls-1"
|
||||
data-name="Path 112" />
|
||||
</svg>
|
After Width: | Height: | Size: 6.9 KiB |
@ -1,15 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap&subset=latin,latin-ext" />
|
||||
<title>Zgłoszenie praktyki studenckiej</title>
|
||||
<base href="/">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="theme-color" content="#000000"/>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap&subset=latin,latin-ext"/>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/>
|
||||
<title>Zgłoszenie praktyki studenckiej</title>
|
||||
<base href="/">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<div id="modals"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
18
src/api/companies.ts
Normal file
18
src/api/companies.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Company, Office } from "@/data";
|
||||
import { axios } from "@/api/index";
|
||||
import { prepare, query } from "@/routing";
|
||||
|
||||
export const COMPANY_SEARCH_ENDPOINT = '/companies';
|
||||
export const COMPANY_OFFICES_ENDPOINT = '/companies/:id'
|
||||
|
||||
export async function search(name: string): Promise<Company[]> {
|
||||
const companies = await axios.get<Company[]>(query(COMPANY_SEARCH_ENDPOINT, { Name: name }));
|
||||
|
||||
return companies.data;
|
||||
}
|
||||
|
||||
export async function offices(id: string): Promise<Office[]> {
|
||||
const response = await axios.get<Office[]>(prepare(COMPANY_OFFICES_ENDPOINT, { id }));
|
||||
|
||||
return response.data;
|
||||
}
|
@ -17,7 +17,6 @@ export const courseDtoTransformer: Transformer<CourseDTO, Course> = {
|
||||
id: subject.id,
|
||||
name: subject.name,
|
||||
desiredSemesters: [],
|
||||
possibleProgramEntries: [], // todo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,25 @@
|
||||
import { Identifiable } from "@/data";
|
||||
import { Identifiable, Identifier, InternshipProgramEntry } from "@/data";
|
||||
import { CourseDTO, courseDtoTransformer } from "@/api/dto/course";
|
||||
import { OneWayTransformer, Transformer } from "@/serialization";
|
||||
import { Edition } from "@/data/edition";
|
||||
import moment from "moment";
|
||||
import moment from "moment-timezone";
|
||||
import { Subset } from "@/helpers";
|
||||
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
|
||||
import { ReportFieldDefinition, ReportFieldType } from "@/data/report";
|
||||
|
||||
export interface ProgramEntryDTO extends Identifiable {
|
||||
description: string;
|
||||
descriptionEng: string;
|
||||
}
|
||||
|
||||
export interface EditionDTO extends Identifiable {
|
||||
editionStart: string,
|
||||
editionFinish: string,
|
||||
reportingStart: string,
|
||||
course: CourseDTO,
|
||||
availableSubjects: ProgramEntryDTO[],
|
||||
availableInternshipTypes: InternshipTypeDTO[],
|
||||
reportSchema: FieldDefinitionDTO[],
|
||||
}
|
||||
|
||||
export interface EditionTeaserDTO extends Identifiable {
|
||||
@ -18,9 +28,86 @@ export interface EditionTeaserDTO extends Identifiable {
|
||||
courseName: string,
|
||||
}
|
||||
|
||||
export enum FieldDefinitionDTOType {
|
||||
LongText = "LongText",
|
||||
ShortText = "ShortText",
|
||||
Select = "Select",
|
||||
Radial = "Radial",
|
||||
Checkbox = "Checkbox",
|
||||
}
|
||||
|
||||
export const fieldDefinitionDtoTypeTransformer: Transformer<FieldDefinitionDTOType, ReportFieldType> = {
|
||||
transform(dto: FieldDefinitionDTOType, context?: unknown) {
|
||||
switch (dto) {
|
||||
case FieldDefinitionDTOType.LongText:
|
||||
return "long-text"
|
||||
case FieldDefinitionDTOType.ShortText:
|
||||
return "short-text";
|
||||
case FieldDefinitionDTOType.Select:
|
||||
return "select";
|
||||
case FieldDefinitionDTOType.Radial:
|
||||
return "radio";
|
||||
case FieldDefinitionDTOType.Checkbox:
|
||||
return "checkbox";
|
||||
}
|
||||
},
|
||||
reverseTransform(type: ReportFieldType, context?: unknown) {
|
||||
switch (type) {
|
||||
case "short-text":
|
||||
return FieldDefinitionDTOType.ShortText;
|
||||
case "long-text":
|
||||
return FieldDefinitionDTOType.LongText;
|
||||
case "checkbox":
|
||||
return FieldDefinitionDTOType.Checkbox;
|
||||
case "radio":
|
||||
return FieldDefinitionDTOType.Radial;
|
||||
case "select":
|
||||
return FieldDefinitionDTOType.Select;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FieldDefinitionDTO extends Identifiable {
|
||||
label: string;
|
||||
labelEng: string;
|
||||
description: string;
|
||||
descriptionEng: string;
|
||||
fieldType: FieldDefinitionDTOType;
|
||||
choices: string[];
|
||||
}
|
||||
|
||||
export const fieldDefinitionDtoTransformer: Transformer<FieldDefinitionDTO, ReportFieldDefinition> = {
|
||||
transform(dto: FieldDefinitionDTO, context?: unknown): ReportFieldDefinition {
|
||||
return {
|
||||
id: dto.id,
|
||||
choices: (dto.choices || []).map(choice => JSON.parse(choice)),
|
||||
description: {
|
||||
pl: dto.description,
|
||||
en: dto.descriptionEng,
|
||||
},
|
||||
label: {
|
||||
pl: dto.label,
|
||||
en: dto.labelEng,
|
||||
},
|
||||
type: fieldDefinitionDtoTypeTransformer.transform(dto.fieldType),
|
||||
}
|
||||
},
|
||||
reverseTransform(subject: ReportFieldDefinition, context?: unknown): FieldDefinitionDTO {
|
||||
return {
|
||||
id: subject.id,
|
||||
choices: "choices" in subject && subject.choices.map(choice => JSON.stringify(choice)) || [],
|
||||
description: subject.description.pl,
|
||||
descriptionEng: subject.description.en,
|
||||
fieldType: fieldDefinitionDtoTypeTransformer.reverseTransform(subject.type),
|
||||
label: subject.label.pl,
|
||||
labelEng: subject.label.en,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const editionTeaserDtoTransformer: OneWayTransformer<EditionTeaserDTO, Subset<Edition>> = {
|
||||
transform(subject: EditionTeaserDTO, context?: undefined): Subset<Edition> {
|
||||
return {
|
||||
return subject && {
|
||||
id: subject.id,
|
||||
startDate: moment(subject.editionStart),
|
||||
endDate: moment(subject.editionFinish),
|
||||
@ -39,6 +126,9 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
|
||||
editionStart: subject.startDate.toISOString(),
|
||||
course: courseDtoTransformer.reverseTransform(subject.course),
|
||||
reportingStart: subject.reportingStart.toISOString(),
|
||||
availableSubjects: subject.program.map(entry => programEntryDtoTransformer.reverseTransform(entry)),
|
||||
availableInternshipTypes: subject.types.map(entry => internshipTypeDtoTransformer.reverseTransform(entry)),
|
||||
reportSchema: subject.schema.map(entry => fieldDefinitionDtoTransformer.reverseTransform(entry)),
|
||||
};
|
||||
},
|
||||
transform(subject: EditionDTO, context: undefined): Edition {
|
||||
@ -52,6 +142,50 @@ export const editionDtoTransformer: Transformer<EditionDTO, Edition> = {
|
||||
proposalDeadline: moment(subject.reportingStart),
|
||||
reportingStart: moment(subject.reportingStart),
|
||||
reportingEnd: moment(subject.reportingStart).add(1, 'month'),
|
||||
program: (subject.availableSubjects || []).map(entry => programEntryDtoTransformer.transform(entry)),
|
||||
types: (subject.availableInternshipTypes || []).map(entry => internshipTypeDtoTransformer.transform(entry)),
|
||||
schema: (subject.reportSchema || []).map(entry => fieldDefinitionDtoTransformer.transform(entry)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const programEntryDtoTransformer: Transformer<ProgramEntryDTO, InternshipProgramEntry> = {
|
||||
transform(subject: ProgramEntryDTO, context: never): InternshipProgramEntry {
|
||||
return {
|
||||
id: subject.id,
|
||||
description: subject.description,
|
||||
}
|
||||
},
|
||||
reverseTransform(subject: InternshipProgramEntry, context: never): ProgramEntryDTO {
|
||||
return {
|
||||
id: subject.id,
|
||||
description: subject.description,
|
||||
descriptionEng: "",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
interface EditionUpdateDTO extends Identifiable {
|
||||
editionStart: string;
|
||||
editionFinish: string;
|
||||
reportingStart: string;
|
||||
course: CourseDTO;
|
||||
availableSubjectsIds: Identifier[],
|
||||
availableInternshipTypesIds: Identifier[],
|
||||
reportSchema: Identifier[],
|
||||
}
|
||||
|
||||
export const editionUpdateDtoTransformer: OneWayTransformer<Edition, EditionUpdateDTO> = {
|
||||
transform(subject: Edition, context?: undefined): EditionUpdateDTO {
|
||||
return {
|
||||
id: subject.id,
|
||||
editionFinish: subject.endDate.toISOString(),
|
||||
editionStart: subject.startDate.toISOString(),
|
||||
course: courseDtoTransformer.reverseTransform(subject.course),
|
||||
reportingStart: subject.reportingStart.toISOString(),
|
||||
availableSubjectsIds: subject.program.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[],
|
||||
availableInternshipTypesIds: subject.types.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[],
|
||||
reportSchema: subject.schema.map(x => x.id).filter(x => typeof x !== "undefined") as Identifier[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
190
src/api/dto/internship-registration.ts
Normal file
190
src/api/dto/internship-registration.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { Address, Company, Identifiable, Internship, Mentor, Office, Stateful } from "@/data";
|
||||
import { momentSerializationTransformer, OneWayTransformer, Transformer } from "@/serialization";
|
||||
import { Nullable } from "@/helpers";
|
||||
import { MentorDTO, mentorDtoTransformer } from "@/api/dto/mentor";
|
||||
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
|
||||
import { Moment } from "moment-timezone";
|
||||
import { sampleStudent } from "@/provider/dummy";
|
||||
import { UploadType } from "@/api/upload";
|
||||
import { ProgramEntryDTO, programEntryDtoTransformer } from "@/api/dto/edition";
|
||||
import { StudentDTO } from "@/api/dto/student";
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
import { Report } from "@/data/report";
|
||||
|
||||
export interface StatefulDTO {
|
||||
state: SubmissionState;
|
||||
changeStateComment: string;
|
||||
}
|
||||
|
||||
export enum SubmissionState {
|
||||
Draft = "Draft",
|
||||
Submitted = "Submitted",
|
||||
Accepted = "Accepted",
|
||||
Rejected = "Rejected",
|
||||
Archival = "Archival",
|
||||
}
|
||||
|
||||
export const submissionStateDtoTransformer: Transformer<SubmissionState, SubmissionStatus> = {
|
||||
reverseTransform(subject: SubmissionStatus, context: undefined): SubmissionState {
|
||||
switch (subject) {
|
||||
case "draft":
|
||||
return SubmissionState.Draft;
|
||||
case "awaiting":
|
||||
return SubmissionState.Submitted;
|
||||
case "accepted":
|
||||
return SubmissionState.Accepted;
|
||||
case "declined":
|
||||
return SubmissionState.Rejected;
|
||||
}
|
||||
},
|
||||
transform(subject: SubmissionState, context: undefined): SubmissionStatus {
|
||||
switch (subject) {
|
||||
case SubmissionState.Draft:
|
||||
return "draft";
|
||||
case SubmissionState.Submitted:
|
||||
return "awaiting";
|
||||
case SubmissionState.Accepted:
|
||||
return "accepted";
|
||||
case SubmissionState.Rejected:
|
||||
return "declined";
|
||||
case SubmissionState.Archival:
|
||||
return "declined";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const statefulDtoTransformer: Transformer<StatefulDTO, Stateful> = {
|
||||
reverseTransform(subject: Stateful, context: undefined): StatefulDTO {
|
||||
return {
|
||||
changeStateComment: subject.comment,
|
||||
state: submissionStateDtoTransformer.reverseTransform(subject.state, context),
|
||||
};
|
||||
},
|
||||
transform(subject: StatefulDTO, context: undefined): Stateful {
|
||||
return {
|
||||
comment: subject.changeStateComment,
|
||||
state: submissionStateDtoTransformer.transform(subject.state),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewBranchOffice extends Address {
|
||||
}
|
||||
|
||||
export interface InternshipRegistrationUpdateCompany {
|
||||
id: string,
|
||||
branchOffice: Identifiable | NewBranchOffice,
|
||||
}
|
||||
|
||||
export interface NewCompany {
|
||||
nip: string;
|
||||
name: string;
|
||||
branchOffice: NewBranchOffice | null;
|
||||
}
|
||||
|
||||
export interface InternshipRegistrationUpdate {
|
||||
company: InternshipRegistrationUpdateCompany | NewCompany,
|
||||
start: string,
|
||||
end: string,
|
||||
type: number,
|
||||
mentor: MentorDTO,
|
||||
hours: number,
|
||||
subjects: string[],
|
||||
}
|
||||
|
||||
export interface InternshipRegistrationDTO extends Identifiable, StatefulDTO {
|
||||
start: string;
|
||||
end: string;
|
||||
type: InternshipTypeDTO,
|
||||
mentor: MentorDTO,
|
||||
company: Company,
|
||||
branchAddress: Office,
|
||||
declaredHours: number,
|
||||
subjects: { subject: ProgramEntryDTO }[],
|
||||
submissionDate: string,
|
||||
}
|
||||
|
||||
export interface InternshipDocument extends Identifiable, Stateful {
|
||||
description: null,
|
||||
type: UploadType,
|
||||
}
|
||||
|
||||
export interface InternshipDocumentDTO extends Identifiable, StatefulDTO {
|
||||
description: null;
|
||||
type: UploadType;
|
||||
}
|
||||
|
||||
export interface InternshipReportDTO extends StatefulDTO, Identifiable {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const reference = (subject: Identifiable | null): Identifiable | null => subject && { id: subject.id };
|
||||
|
||||
export interface InternshipInfoDTO extends Identifiable {
|
||||
internshipRegistration: InternshipRegistrationDTO;
|
||||
documentation: InternshipDocumentDTO[],
|
||||
student: StudentDTO,
|
||||
report: InternshipReportDTO,
|
||||
grade: number,
|
||||
}
|
||||
|
||||
export const internshipReportDtoTransformer: OneWayTransformer<InternshipReportDTO, Report> = {
|
||||
transform(subject: InternshipReportDTO, context?: unknown): Report {
|
||||
return {
|
||||
id: subject.id,
|
||||
fields: JSON.parse(subject.value),
|
||||
...statefulDtoTransformer.transform(subject),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const internshipRegistrationUpdateTransformer: OneWayTransformer<Nullable<Internship>, Nullable<InternshipRegistrationUpdate>> = {
|
||||
transform(subject: Nullable<Internship>, context?: unknown): Nullable<InternshipRegistrationUpdate> {
|
||||
return {
|
||||
start: momentSerializationTransformer.transform(subject?.startDate) || null,
|
||||
end: momentSerializationTransformer.transform(subject?.endDate) || null,
|
||||
type: parseInt(subject?.type?.id || "0"),
|
||||
mentor: mentorDtoTransformer.reverseTransform(subject.mentor as Mentor),
|
||||
company: subject?.company?.id ? {
|
||||
id: subject?.company?.id as string,
|
||||
branchOffice: subject?.office?.id
|
||||
? reference(subject?.office) as Identifiable
|
||||
: subject?.office?.address as NewBranchOffice,
|
||||
} : {
|
||||
name: subject?.company?.name as string,
|
||||
nip: subject?.company?.nip as string,
|
||||
branchOffice: subject?.office?.address as NewBranchOffice
|
||||
},
|
||||
hours: subject?.hours,
|
||||
subjects: subject?.program?.map(program => program.id as string) || [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const internshipRegistrationDtoTransformer: OneWayTransformer<InternshipRegistrationDTO, Internship> = {
|
||||
transform(dto: InternshipRegistrationDTO, context?: unknown): Internship {
|
||||
return {
|
||||
id: dto.id,
|
||||
office: dto.branchAddress,
|
||||
company: dto.company,
|
||||
mentor: mentorDtoTransformer.transform(dto.mentor),
|
||||
startDate: momentSerializationTransformer.reverseTransform(dto.start) as Moment,
|
||||
endDate: momentSerializationTransformer.reverseTransform(dto.end) as Moment,
|
||||
type: internshipTypeDtoTransformer.transform(dto.type),
|
||||
hours: dto.declaredHours,
|
||||
isAccepted: dto.state === SubmissionState.Accepted,
|
||||
lengthInWeeks: 0,
|
||||
program: dto.subjects.map(subject => programEntryDtoTransformer.transform(subject.subject)),
|
||||
intern: sampleStudent, // fixme
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const internshipDocumentDtoTransformer: OneWayTransformer<InternshipDocumentDTO, InternshipDocument> = {
|
||||
transform(dto: InternshipDocumentDTO, context?: unknown): InternshipDocument {
|
||||
return {
|
||||
...dto,
|
||||
...statefulDtoTransformer.transform(dto),
|
||||
}
|
||||
}
|
||||
}
|
28
src/api/dto/mentor.ts
Normal file
28
src/api/dto/mentor.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Transformer } from "@/serialization";
|
||||
import { Mentor } from "@/data";
|
||||
|
||||
export interface MentorDTO {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export const mentorDtoTransformer: Transformer<MentorDTO, Mentor> = {
|
||||
reverseTransform(subject: Mentor, context?: unknown): MentorDTO {
|
||||
return {
|
||||
firstName: subject.name,
|
||||
lastName: subject.surname,
|
||||
email: subject.email,
|
||||
phoneNumber: subject.phone || "",
|
||||
}
|
||||
},
|
||||
transform(subject: MentorDTO, context?: unknown): Mentor {
|
||||
return {
|
||||
name: subject.firstName,
|
||||
surname: subject.lastName,
|
||||
email: subject.email,
|
||||
phone: subject.phoneNumber,
|
||||
}
|
||||
}
|
||||
}
|
36
src/api/dto/type.ts
Normal file
36
src/api/dto/type.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Identifiable, InternshipType } from "@/data";
|
||||
import { Transformer } from "@/serialization";
|
||||
|
||||
export interface InternshipTypeDTO extends Identifiable {
|
||||
label: string;
|
||||
labelEng: string;
|
||||
description?: string;
|
||||
descriptionEng?: string;
|
||||
}
|
||||
|
||||
export const internshipTypeDtoTransformer: Transformer<InternshipTypeDTO, InternshipType> = {
|
||||
transform(subject: InternshipTypeDTO, context?: unknown): InternshipType {
|
||||
return {
|
||||
id: subject.id,
|
||||
label: {
|
||||
pl: subject.label,
|
||||
en: subject.labelEng
|
||||
},
|
||||
description: subject.description ? {
|
||||
pl: subject.description,
|
||||
en: subject.descriptionEng || ""
|
||||
} : undefined,
|
||||
requiresDeanApproval: parseInt(subject.id || "0") == 4,
|
||||
requiresInsurance: parseInt(subject.id || "0") >= 4,
|
||||
}
|
||||
},
|
||||
reverseTransform(subject: InternshipType, context?: unknown): InternshipTypeDTO {
|
||||
return {
|
||||
id: subject.id,
|
||||
label: subject.label.pl,
|
||||
labelEng: subject.label.en,
|
||||
description: subject.description?.pl || undefined,
|
||||
descriptionEng: subject.description?.en || undefined,
|
||||
}
|
||||
},
|
||||
}
|
@ -1,21 +1,29 @@
|
||||
import { axios } from "@/api/index";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { prepare } from "@/routing";
|
||||
import { EditionDTO, editionDtoTransformer, editionTeaserDtoTransformer } from "@/api/dto/edition";
|
||||
import { EditionDTO, editionDtoTransformer, EditionTeaserDTO, editionTeaserDtoTransformer, programEntryDtoTransformer } from "@/api/dto/edition";
|
||||
import { Subset } from "@/helpers";
|
||||
import { InternshipProgramEntry } from "@/data";
|
||||
|
||||
const EDITIONS_ENDPOINT = "/editions";
|
||||
const EDITION_INFO_ENDPOINT = "/editions/:key";
|
||||
const REGISTER_ENDPOINT = "/register";
|
||||
const EDITION_CURRENT_ENDPOINT = "/editions/current";
|
||||
const EDITION_REGISTER_ENDPOINT = "/register";
|
||||
const EDITION_LOGIN_ENDPOINT = "/access/loginEdition";
|
||||
|
||||
export async function available() {
|
||||
const response = await axios.get(EDITIONS_ENDPOINT);
|
||||
try {
|
||||
const response = await axios.get(EDITIONS_ENDPOINT);
|
||||
|
||||
return (response.data || []).map(editionTeaserDtoTransformer.transform);
|
||||
return (response.data || []).map(editionTeaserDtoTransformer.transform);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function join(key: string): Promise<boolean> {
|
||||
try {
|
||||
await axios.post(REGISTER_ENDPOINT, JSON.stringify(key), { headers: { "Content-Type": "application/json" } });
|
||||
await axios.post(EDITION_REGISTER_ENDPOINT, JSON.stringify(key), { headers: { "Content-Type": "application/json" } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@ -23,9 +31,32 @@ export async function join(key: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(key: string): Promise<Edition | null> {
|
||||
const response = await axios.get<EditionDTO>(prepare(EDITION_INFO_ENDPOINT, { key }));
|
||||
export async function get(key: string): Promise<Subset<Edition> | null> {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await axios.get<EditionTeaserDTO>(prepare(EDITION_INFO_ENDPOINT, { key }));
|
||||
const dto = response.data;
|
||||
|
||||
return editionDtoTransformer.transform(dto);
|
||||
return editionTeaserDtoTransformer.transform(dto);
|
||||
}
|
||||
|
||||
export async function current(): Promise<{
|
||||
edition: Edition,
|
||||
program: InternshipProgramEntry[],
|
||||
}> {
|
||||
const response = await axios.get<EditionDTO>(EDITION_CURRENT_ENDPOINT);
|
||||
const dto = response.data;
|
||||
|
||||
return {
|
||||
edition: editionDtoTransformer.transform(dto),
|
||||
program: dto.availableSubjects.map(programEntryDtoTransformer.transform as any),
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(key: string): Promise<string> {
|
||||
const response = await axios.post<string>(EDITION_LOGIN_ENDPOINT, JSON.stringify(key), { headers: { "Content-Type": "application/json" } })
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
@ -5,11 +5,16 @@ import { UserState } from "@/state/reducer/user";
|
||||
|
||||
import * as user from "./user";
|
||||
import * as edition from "./edition";
|
||||
import * as page from "./page"
|
||||
import * as student from "./student"
|
||||
import * as page from "./page";
|
||||
import * as student from "./student";
|
||||
import * as type from "./type";
|
||||
import * as companies from "./companies";
|
||||
import * as internship from "./internship";
|
||||
import * as upload from "./upload";
|
||||
import * as report from "./report";
|
||||
|
||||
export const axios = Axios.create({
|
||||
baseURL: process.env.API_BASE_URL || "https://system-praktyk.stg.kadet.net/api/",
|
||||
baseURL: process.env.API_BASE_URL || `${window.location.protocol}//${window.location.hostname}/api/`,
|
||||
})
|
||||
|
||||
axios.interceptors.request.use(config => {
|
||||
@ -33,7 +38,12 @@ const api = {
|
||||
user,
|
||||
edition,
|
||||
page,
|
||||
student
|
||||
student,
|
||||
type,
|
||||
companies,
|
||||
internship,
|
||||
upload,
|
||||
report,
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
47
src/api/internship.ts
Normal file
47
src/api/internship.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { InternshipInfoDTO, InternshipRegistrationUpdate, SubmissionState } from "@/api/dto/internship-registration";
|
||||
import { axios } from "@/api/index";
|
||||
import { Nullable } from "@/helpers";
|
||||
|
||||
const INTERNSHIP_REGISTRATION_ENDPOINT = '/internshipRegistration';
|
||||
const INTERNSHIP_ENDPOINT = '/internship';
|
||||
|
||||
export type ValidationMessage = {
|
||||
key: string;
|
||||
parameters: { [name: string]: string },
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
public readonly messages: ValidationMessage[];
|
||||
|
||||
constructor(messages: ValidationMessage[], message: string = "There were validation errors.") {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, ValidationError.prototype);
|
||||
|
||||
this.messages = messages;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
key: string;
|
||||
parameters: { [name: string]: string },
|
||||
}
|
||||
|
||||
interface UpdateResponse {
|
||||
status: SubmissionState;
|
||||
errors?: ApiError[];
|
||||
}
|
||||
|
||||
export async function update(internship: Nullable<InternshipRegistrationUpdate>): Promise<SubmissionState> {
|
||||
const response = (await axios.put<UpdateResponse>(INTERNSHIP_REGISTRATION_ENDPOINT, internship)).data;
|
||||
|
||||
if (response.status == SubmissionState.Draft) {
|
||||
throw new ValidationError(response.errors || []);
|
||||
}
|
||||
|
||||
return response.status;
|
||||
}
|
||||
|
||||
export async function get(): Promise<InternshipInfoDTO> {
|
||||
const response = await axios.get<InternshipInfoDTO>(INTERNSHIP_ENDPOINT);
|
||||
return response.data;
|
||||
}
|
@ -3,7 +3,7 @@ import { PageDTO, pageDtoTransformer } from "./dto/page"
|
||||
import { axios } from "@/api/index";
|
||||
import { prepare } from "@/routing";
|
||||
|
||||
const STATIC_PAGE_ENDPOINT = "/staticPage/:slug"
|
||||
export const STATIC_PAGE_ENDPOINT = "/staticPage/:slug"
|
||||
|
||||
export async function get(slug: string): Promise<Page> {
|
||||
const response = await axios.get<PageDTO>(prepare(STATIC_PAGE_ENDPOINT, { slug }))
|
8
src/api/report.ts
Normal file
8
src/api/report.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Report, ReportFieldValues } from "@/data/report";
|
||||
import { axios } from "@/api/index";
|
||||
|
||||
const REPORT_SAVE_ENDPOINT = "/internship/report"
|
||||
|
||||
export async function save(report: Report) {
|
||||
await axios.post(REPORT_SAVE_ENDPOINT, report.fields);
|
||||
}
|
@ -11,3 +11,9 @@ export async function current(): Promise<Student> {
|
||||
return studentDtoTransfer.transform(dto);
|
||||
}
|
||||
|
||||
export async function update(student: Student): Promise<Student> {
|
||||
const dto = studentDtoTransfer.reverseTransform(student);
|
||||
const response = await axios.put(CURRENT_STUDENT_ENDPOINT, dto);
|
||||
|
||||
return student;
|
||||
}
|
||||
|
12
src/api/type.ts
Normal file
12
src/api/type.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { InternshipType } from "@/data";
|
||||
import { axios } from "@/api/index";
|
||||
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
|
||||
|
||||
const AVAILABLE_INTERNSHIP_TYPES = '/internshipTypes/current';
|
||||
|
||||
export async function available(): Promise<InternshipType[]> {
|
||||
const response = await axios.get<InternshipTypeDTO[]>(AVAILABLE_INTERNSHIP_TYPES);
|
||||
const dtos = response.data;
|
||||
|
||||
return dtos.map(dto => internshipTypeDtoTransformer.transform(dto));
|
||||
}
|
44
src/api/upload.ts
Normal file
44
src/api/upload.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { axios } from "@/api/index";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
import { prepare } from "@/routing";
|
||||
import { Identifiable } from "@/data";
|
||||
import store from "@/state/store";
|
||||
|
||||
export enum UploadType {
|
||||
Ipp = "IppScan",
|
||||
DeanConsent = "DeanConsent",
|
||||
Insurance = "NnwInsurance",
|
||||
InternshipEvaluation = "InternshipEvaluation"
|
||||
}
|
||||
|
||||
export interface DocumentFileInfo extends Identifiable {
|
||||
filename: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
const CREATE_DOCUMENT_ENDPOINT = '/document';
|
||||
const DOCUMENT_UPLOAD_ENDPOINT = '/document/:id/scan';
|
||||
const DOCUMENT_DOWNLOAD_ENDPOINT = 'document/:id/scan/download';
|
||||
|
||||
export async function create(type: UploadType) {
|
||||
const response = await axios.post<InternshipDocument>(CREATE_DOCUMENT_ENDPOINT, { type });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function upload(document: InternshipDocument, file: File) {
|
||||
const data = new FormData();
|
||||
data.append('documentScan', file)
|
||||
|
||||
const response = await axios.put(prepare(DOCUMENT_UPLOAD_ENDPOINT, { id: document.id as string }), data);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function fileinfo(document: InternshipDocument): Promise<DocumentFileInfo> {
|
||||
const response = await axios.get<DocumentFileInfo>(prepare(DOCUMENT_UPLOAD_ENDPOINT, { id: document.id as string }));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function link(document: InternshipDocument): string {
|
||||
return axios.defaults.baseURL + prepare(DOCUMENT_DOWNLOAD_ENDPOINT, { id: document.id as string }) + "?auth=" + store.getState().user.token;
|
||||
}
|
@ -1,13 +1,16 @@
|
||||
import { axios } from "@/api/index";
|
||||
import { query, route } from "@/routing";
|
||||
|
||||
const LOGIN_ENDPOINT = "/access/login"
|
||||
const LOGIN_ENDPOINT = "/access/login";
|
||||
const DEV_LOGIN_ENDPOINT = "/dev/login";
|
||||
|
||||
const CLIENT_ID = process.env.LOGIN_CLIENT_ID || "PraktykiClientId";
|
||||
const AUTHORIZE_URL = process.env.AUTHORIZE || "https://logowanie.pg.edu.pl/oauth2.0/authorize";
|
||||
|
||||
export async function login(code: string): Promise<string> {
|
||||
const response = await axios.get<string>(LOGIN_ENDPOINT, { params: { code }});
|
||||
export async function login(code?: string): Promise<string> {
|
||||
const response = code
|
||||
? await axios.post<string>(LOGIN_ENDPOINT, JSON.stringify(code), { headers: { 'Content-Type': 'application/json' } })
|
||||
: await axios.get<string>(DEV_LOGIN_ENDPOINT);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
64
src/app.tsx
64
src/app.tsx
@ -1,6 +1,6 @@
|
||||
import React, { HTMLProps, useEffect } from 'react';
|
||||
import { Link, Route, Switch } from "react-router-dom"
|
||||
import { processMiddlewares, route, routes } from "@/routing";
|
||||
import { processMiddlewares, route, Routes, routes } from "@/routing";
|
||||
import { useSelector } from "react-redux";
|
||||
import { AppState } from "@/state/reducer";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@ -9,13 +9,13 @@ import '@/styles/overrides.scss'
|
||||
import '@/styles/header.scss'
|
||||
import '@/styles/footer.scss'
|
||||
import classNames from "classnames";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { SettingActions } from "@/state/actions/settings";
|
||||
import { useDispatch, UserActions } from "@/state/actions";
|
||||
import { getLocale, Locale } from "@/state/reducer/settings";
|
||||
import i18n from "@/i18n";
|
||||
import moment from "moment";
|
||||
import moment from "moment-timezone";
|
||||
import { Container } from "@material-ui/core";
|
||||
import { useCurrentUser } from "@/hooks";
|
||||
|
||||
const UserMenu = (props: HTMLProps<HTMLUListElement>) => {
|
||||
const student = useSelector<AppState, Student>(state => state.student as Student);
|
||||
@ -61,10 +61,9 @@ const LanguageSwitcher = ({ className, ...props }: HTMLProps<HTMLUListElement>)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const dispatch = useDispatch();
|
||||
const edition = useSelector<AppState, Edition | null>(state => state.edition);
|
||||
const { t } = useTranslation();
|
||||
const locale = useSelector<AppState, Locale>(state => getLocale(state.settings));
|
||||
const user = useCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(locale);
|
||||
@ -73,37 +72,38 @@ function App() {
|
||||
}, [ locale ])
|
||||
|
||||
return <>
|
||||
<header className="header">
|
||||
<div id="logo" className="header__logo">
|
||||
<Link to={ route('home') }>
|
||||
<img src="img/pg-logotyp.svg"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="header__nav">
|
||||
<nav className="header__top">
|
||||
<ul className="header__menu">
|
||||
</ul>
|
||||
<UserMenu className="header__user"/>
|
||||
<div className="header__divider"/>
|
||||
<LanguageSwitcher className="header__language-switcher"/>
|
||||
</nav>
|
||||
<nav className="header__bottom">
|
||||
<ul className="header__menu header__menu--main">
|
||||
<li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
|
||||
<li><Link to="/regulamin">Regulamin</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<header>
|
||||
<Container className="header">
|
||||
<div id="logo" className="header__logo">
|
||||
<Link to={ route('home') }>
|
||||
<img src="img/pg-logotyp.svg"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="header__nav">
|
||||
<nav className="header__top">
|
||||
<ul className="header__menu">
|
||||
</ul>
|
||||
<UserMenu className="header__user"/>
|
||||
<div className="header__divider"/>
|
||||
<LanguageSwitcher className="header__language-switcher"/>
|
||||
</nav>
|
||||
<nav className="header__bottom">
|
||||
<ul className="header__menu header__menu--main">
|
||||
<li><Link to={ route("home") }>{ t("pages.my-internship.header") }</Link></li>
|
||||
<li><Link to="/regulations">Regulamin</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
<main id="content">
|
||||
{ <Switch>
|
||||
{ routes.map(({ name, content, middlewares = [], ...route }) => <Route { ...route } key={ name }>
|
||||
{ processMiddlewares([ ...middlewares, content ]) }
|
||||
</Route>) }
|
||||
</Switch> }
|
||||
<Routes routes={ routes.filter(route => !route.tags || route.tags.length == 0) }/>
|
||||
</main>
|
||||
<footer className="footer">
|
||||
<Container>
|
||||
<Container style={{ display: 'flex', alignItems: "center" }}>
|
||||
<ul className="footer__menu">
|
||||
{ user?.isManager && <li><Link to="/management">{ t("management") }</Link></li> }
|
||||
</ul>
|
||||
<div className="footer__copyright">{ t('copyright', { date: moment() }) }</div>
|
||||
</Container>
|
||||
</footer>
|
||||
|
146
src/components/acceptance-action.tsx
Normal file
146
src/components/acceptance-action.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonProps,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogProps,
|
||||
DialogTitle, FormControl,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@material-ui/core";
|
||||
import { MenuDown, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVerticalSpacing } from "@/styles";
|
||||
import { createPortal } from "react-dom";
|
||||
// @ts-ignore
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
// @ts-ignore
|
||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
|
||||
type AcceptSubmissionDialogProps = {
|
||||
onAccept: (comment?: string) => void;
|
||||
label: string;
|
||||
} & DialogProps;
|
||||
|
||||
export function AcceptSubmissionDialog({ onAccept, label, ...props }: AcceptSubmissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState<string>("");
|
||||
const classes = useVerticalSpacing(3);
|
||||
|
||||
return <Dialog maxWidth="xl" { ...props }>
|
||||
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
|
||||
<DialogContent className={ classes.root }>
|
||||
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
|
||||
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>
|
||||
{ t('cancel') }
|
||||
</Button>
|
||||
<Button onClick={ () => onAccept?.(comment) } color="primary" variant="contained">
|
||||
{ t('confirm') }
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
type DiscardSubmissionDialogProps = {
|
||||
onDiscard: (comment: string) => void;
|
||||
label: string;
|
||||
} & DialogProps;
|
||||
|
||||
export function DiscardSubmissionDialog({ onDiscard, label, ...props }: DiscardSubmissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState<string>("");
|
||||
const classes = useVerticalSpacing(3);
|
||||
|
||||
return <Dialog maxWidth="xl" { ...props }>
|
||||
<DialogTitle>{ t(label + ".accept.title") }</DialogTitle>
|
||||
<DialogContent className={ classes.root }>
|
||||
<Typography variant="body1">{ t(label + ".accept.info") }</Typography>
|
||||
<CKEditor data={ comment } editor={ ClassicEditor } onChange={ (_: any, ed: any) => setComment(ed.getData()) }/>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={ ev => props.onClose?.(ev, "backdropClick") }>
|
||||
{ t('cancel') }
|
||||
</Button>
|
||||
<Button onClick={ () => onDiscard?.(comment) } color="primary" variant="contained">
|
||||
{ t('confirm') }
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
type AcceptanceActionsProps = {
|
||||
onAccept: (comment?: string) => void;
|
||||
onDiscard: (comment: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AcceptanceActions({ onAccept, onDiscard, label }: AcceptanceActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isDiscardModalOpen, setDiscardModelOpen] = useState<boolean>(false);
|
||||
const [isAcceptModalOpen, setAcceptModelOpen] = useState<boolean>(false);
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleAcceptModalClose = () => {
|
||||
setAcceptModelOpen(false);
|
||||
}
|
||||
|
||||
const handleDiscardModalClose = () => {
|
||||
setDiscardModelOpen(false);
|
||||
}
|
||||
|
||||
const handleDiscardAction = () => {
|
||||
setDiscardModelOpen(true);
|
||||
}
|
||||
|
||||
const handleAcceptMenuOpen = (ev: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchor(ev.currentTarget);
|
||||
}
|
||||
|
||||
const handleAcceptMenuClose = () => {
|
||||
setMenuAnchor(null);
|
||||
}
|
||||
|
||||
const handleAcceptWithComment = () => {
|
||||
setAcceptModelOpen(true);
|
||||
setMenuAnchor(null);
|
||||
}
|
||||
|
||||
const handleAcceptWithoutComment = () => {
|
||||
onAccept();
|
||||
}
|
||||
|
||||
return <>
|
||||
<ButtonGroup color="primary" variant="contained">
|
||||
<Button onClick={ handleAcceptWithoutComment } startIcon={ <StickerCheckOutline /> }>
|
||||
{ t('accept-without-comments') }
|
||||
</Button>
|
||||
<Button size="small" onClick={ handleAcceptMenuOpen }><MenuDown /></Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu open={ !!menuAnchor } anchorEl={ menuAnchor } onClose={ handleAcceptMenuClose }>
|
||||
<MenuItem onClick={ handleAcceptWithoutComment }>{ t("accept-without-comments") }</MenuItem>
|
||||
<MenuItem onClick={ handleAcceptWithComment }>{ t("accept-with-comments") }</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button onClick={ handleDiscardAction } color="secondary" startIcon={ <StickerRemoveOutline /> }>
|
||||
{ t('discard') }
|
||||
</Button>
|
||||
|
||||
{ createPortal(<>
|
||||
<DiscardSubmissionDialog open={ isDiscardModalOpen } onClose={ handleDiscardModalClose } maxWidth="md" onDiscard={ onDiscard } label={ label }/>
|
||||
<AcceptSubmissionDialog open={ isAcceptModalOpen } onClose={ handleAcceptModalClose } maxWidth="md" onAccept={ onAccept } label={ label }/>
|
||||
</>, document.getElementById("modals") as Element) }
|
||||
</>
|
||||
}
|
@ -1,8 +1,12 @@
|
||||
import React, { HTMLProps } from "react";
|
||||
import { useHorizontalSpacing } from "@/styles";
|
||||
|
||||
export const Actions = (props: HTMLProps<HTMLDivElement>) => {
|
||||
const classes = useHorizontalSpacing(2);
|
||||
export type ActionsProps = {
|
||||
spacing?: number;
|
||||
} & HTMLProps<HTMLDivElement>;
|
||||
|
||||
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center" }}/>
|
||||
export const Actions = ({ spacing = 2, ...props }: ActionsProps) => {
|
||||
const classes = useHorizontalSpacing(spacing);
|
||||
|
||||
return <div className={ classes.root } { ...props } style={{ display: "flex", alignItems: "center", ...props.style }}/>
|
||||
}
|
||||
|
29
src/components/async.tsx
Normal file
29
src/components/async.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { AsyncResult } from "@/hooks";
|
||||
import React from "react";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import { Loading } from "@/components/loading";
|
||||
|
||||
type AsyncProps<TValue, TError = any> = {
|
||||
async: AsyncResult<TValue>,
|
||||
children: (value: TValue) => JSX.Element,
|
||||
loading?: () => JSX.Element,
|
||||
error?: (error: TError) => JSX.Element,
|
||||
keepValue?: boolean;
|
||||
}
|
||||
|
||||
const defaultLoading = () => <Loading />;
|
||||
const defaultError = (error: any) => <Alert severity="error">{ error.message }</Alert>;
|
||||
|
||||
export function Async<TValue, TError = any>(
|
||||
{ async, children: render, loading = defaultLoading, error = defaultError, keepValue = false }: AsyncProps<TValue, TError>
|
||||
) {
|
||||
if (async.value && (!async.isLoading || keepValue)) {
|
||||
return render(async.value as TValue);
|
||||
}
|
||||
|
||||
if (typeof async.error !== "undefined") {
|
||||
return error(async.error);
|
||||
}
|
||||
|
||||
return loading();
|
||||
}
|
46
src/components/confirm.tsx
Normal file
46
src/components/confirm.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, ButtonProps, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type ConfirmProps = {
|
||||
children: (action: () => void) => React.ReactNode,
|
||||
title?: string,
|
||||
content?: React.ReactNode,
|
||||
onConfirm?: () => void,
|
||||
onCancel?: () => void,
|
||||
confirm?: (props: Pick<ButtonProps, 'onClick'>) => React.ReactNode,
|
||||
}
|
||||
|
||||
export function Confirm({ children, title, content, confirm, onConfirm, onCancel }: ConfirmProps) {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
onConfirm?.();
|
||||
}
|
||||
|
||||
return <>
|
||||
{ children(() => { setOpen(true) }) }
|
||||
{ createPortal(
|
||||
<Dialog open={ open } onClose={ handleCancel }>
|
||||
{ title && <DialogTitle>{ title }</DialogTitle>}
|
||||
<DialogContent>
|
||||
<DialogContentText>{ content || t('confirmation') }</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{ confirm ? confirm({ onClick: handleConfirm }) : <Button color="primary" variant="contained" autoFocus onClick={ handleConfirm }>{ t('confirm') }</Button> }
|
||||
<Button onClick={ handleCancel }>{ t('cancel') }</Button>
|
||||
</DialogActions>
|
||||
</Dialog>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>
|
||||
}
|
74
src/components/contact.tsx
Normal file
74
src/components/contact.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Field, Form, Formik } from "formik";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, Typography } from "@material-ui/core";
|
||||
import { CKEditorField } from "@/forms/ckeditor";
|
||||
import { Actions } from "@/components/actions";
|
||||
import { Cancel, Send } from "mdi-material-ui";
|
||||
import { createPortal } from "react-dom";
|
||||
import { capitalize } from "@/helpers";
|
||||
|
||||
export type ContactFormValues = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const initialContactFormValues: ContactFormValues = {
|
||||
content: "",
|
||||
}
|
||||
|
||||
export type ContactDialogProps = {
|
||||
onSend: (values: ContactFormValues) => void;
|
||||
} & DialogProps;
|
||||
|
||||
export function ContactForm() {
|
||||
const { t } = useTranslation();
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical } style={{ overflow: 'hidden' }}>
|
||||
<Field label={ t("forms.contact.field.content") } name="content" component={ CKEditorField }/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ContactDialog({ onSend, ...props }: ContactDialogProps) {
|
||||
const spacing = useSpacing(2);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Dialog { ...props } maxWidth="lg">
|
||||
<Formik initialValues={ initialContactFormValues } onSubmit={ onSend }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<DialogTitle>{ capitalize(t("forms.contact.title")) }</DialogTitle>
|
||||
<DialogContent>
|
||||
<ContactForm />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Send /> } type="submit">{ t("send") }</Button>
|
||||
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
export type ContactActionProps = {
|
||||
children: (props: { action: () => void }) => React.ReactNode
|
||||
};
|
||||
|
||||
export function ContactAction({ children }: ContactActionProps) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const handleClose = () => { setOpen(false) };
|
||||
const handleSubmit = (values: ContactFormValues) => {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
{ children({ action: () => setOpen(true) }) }
|
||||
{ createPortal(
|
||||
<ContactDialog open={ open } onSend={ handleSubmit } onClose={ handleClose }/>,
|
||||
document.getElementById("modals") as HTMLElement
|
||||
) }
|
||||
</>
|
||||
}
|
93
src/components/fileinfo.tsx
Normal file
93
src/components/fileinfo.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
import { useAsync } from "@/hooks";
|
||||
import { DocumentFileInfo } from "@/api/upload";
|
||||
import React, { useCallback } from "react";
|
||||
import api from "@/api";
|
||||
import { Async } from "@/components/async";
|
||||
import { Button, Grid, Paper, PaperProps, SvgIconProps, Theme, Typography } from "@material-ui/core";
|
||||
import { makeStyles, createStyles } from "@material-ui/core/styles";
|
||||
import filesize from "filesize";
|
||||
import { Actions } from "@/components/actions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileDownloadOutline, FileOutline, FileImageOutline, FilePdfOutline, FileWordOutline } from "mdi-material-ui";
|
||||
import classNames from "classnames";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => createStyles({
|
||||
root: {
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: "#e9f0f5",
|
||||
},
|
||||
header: {
|
||||
color: theme.palette.primary.dark,
|
||||
},
|
||||
download: {
|
||||
color: theme.palette.primary.dark,
|
||||
},
|
||||
actions: {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
icon: {
|
||||
fontSize: "6rem",
|
||||
margin: "0 auto",
|
||||
},
|
||||
grid: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconColumn: {
|
||||
flex: "0 1 auto",
|
||||
marginRight: "1rem",
|
||||
color: theme.palette.primary.dark + "af",
|
||||
},
|
||||
asideColumn: {
|
||||
flex: "1 1 auto"
|
||||
}
|
||||
}))
|
||||
|
||||
export type FileInfoProps = {
|
||||
document: InternshipDocument
|
||||
} & PaperProps;
|
||||
|
||||
export type FileIconProps = {
|
||||
mime: string;
|
||||
} & SvgIconProps;
|
||||
|
||||
export function FileIcon({ mime, ...props }: FileIconProps) {
|
||||
switch (true) {
|
||||
case ["application/pdf", "application/x-pdf"].includes(mime):
|
||||
return <FilePdfOutline {...props} />
|
||||
case mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return <FileWordOutline {...props} />
|
||||
case mime.startsWith("image/"):
|
||||
return <FileImageOutline {...props} />
|
||||
default:
|
||||
return <FileOutline {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
export const FileInfo = ({ document, ...props }: FileInfoProps) => {
|
||||
const fileinfo = useAsync<DocumentFileInfo>(useCallback(() => api.upload.fileinfo(document), [document.id]));
|
||||
const classes = useStyles();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Paper variant="outlined" { ...props } className={ classNames(classes.root, props.className) }>
|
||||
<Async async={ fileinfo }>
|
||||
{ fileinfo => <div className={ classes.grid }>
|
||||
<div className={ classes.iconColumn }>
|
||||
<FileIcon mime={ fileinfo.mime || "" } className={ classes.icon } />
|
||||
</div>
|
||||
<aside className={ classes.asideColumn }>
|
||||
<Typography variant="h5" className={ classes.header }>{ fileinfo.filename }</Typography>
|
||||
<Typography variant="subtitle2">
|
||||
{ filesize(fileinfo.size) } • { fileinfo.mime }
|
||||
</Typography>
|
||||
|
||||
<Actions className={ classes.actions }>
|
||||
<Button className={ classes.download } startIcon={ <FileDownloadOutline /> } href={ api.upload.link(document) }>{ t("download") }</Button>
|
||||
</Actions>
|
||||
</aside>
|
||||
</div> }
|
||||
</Async>
|
||||
</Paper>
|
||||
}
|
28
src/components/loading.tsx
Normal file
28
src/components/loading.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { createStyles, makeStyles } from "@material-ui/core/styles";
|
||||
import { CircularProgress, Typography } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles(theme => createStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
"& > :not(:last-child)": {
|
||||
marginBottom: theme.spacing(2),
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
export type LoadingProps = {
|
||||
size?: string | number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function Loading({ size, label, ...props }: LoadingProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={ classes.root } { ...props }>
|
||||
<CircularProgress size={ size }/>
|
||||
{ label && <Typography variant="subtitle1" color="primary">{ label }</Typography> }
|
||||
</div>
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
import { Internship, internshipTypeLabels } from "@/data";
|
||||
import { Internship } from "@/data";
|
||||
import React from "react";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import { List, Typography, ListItem, ListItemIcon, ListItemText } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { useVerticalSpacing } from "@/styles";
|
||||
import moment from "moment";
|
||||
import moment from "moment-timezone";
|
||||
import { Label, Section } from "@/components/section";
|
||||
import { StudentPreview } from "@/pages/user/profile";
|
||||
import { Check, StickerCheck } from "mdi-material-ui";
|
||||
|
||||
export type ProposalPreviewProps = {
|
||||
proposal: Internship;
|
||||
@ -19,15 +21,10 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
|
||||
|
||||
return <div className={ classNames("proposal", classes.root) }>
|
||||
<div>
|
||||
<Typography className="proposal__primary">{ proposal.intern.name } { proposal.intern.surname }</Typography>
|
||||
<Typography className="proposal__secondary">
|
||||
{ t('internship.intern.semester', { semester: proposal.intern.semester }) }
|
||||
{ ", " }
|
||||
{ t('internship.intern.album', { album: proposal.intern.albumNumber }) }
|
||||
</Typography>
|
||||
<StudentPreview student={ proposal.intern } />
|
||||
</div>
|
||||
|
||||
<Section>
|
||||
{ proposal.company && proposal.office && <Section>
|
||||
<Label>{ t('internship.sections.place') }</Label>
|
||||
<Typography className="proposal__primary">
|
||||
{ proposal.company.name }
|
||||
@ -39,11 +36,21 @@ export const ProposalPreview = ({ proposal }: ProposalPreviewProps) => {
|
||||
<Label>{ t('internship.office') }</Label>
|
||||
<Typography className="proposal__primary">{ t('internship.address.city', proposal.office.address) }</Typography>
|
||||
<Typography className="proposal__secondary">{ t('internship.address.street', proposal.office.address) }</Typography>
|
||||
</Section>
|
||||
</Section> }
|
||||
|
||||
{ proposal.type && <Section>
|
||||
<Label>{ t('internship.sections.kind') }</Label>
|
||||
<Typography className="proposal__primary">{ proposal.type.label.pl }</Typography>
|
||||
</Section> }
|
||||
|
||||
<Section>
|
||||
<Label>{ t('internship.sections.kind') }</Label>
|
||||
<Typography className="proposal__primary">{ internshipTypeLabels[proposal.type].label }</Typography>
|
||||
<Label>{ t('internship.sections.program') }</Label>
|
||||
<List>
|
||||
{ proposal.program.map(subject => <ListItem key={ subject.id }>
|
||||
<ListItemIcon><StickerCheck /></ListItemIcon>
|
||||
<ListItemText>{ subject.description }</ListItemText>
|
||||
</ListItem>) }
|
||||
</List>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
|
@ -1,10 +1,11 @@
|
||||
import moment, { Moment } from "moment";
|
||||
import moment, { Moment } from "moment-timezone";
|
||||
import { Box, Step as StepperStep, StepContent, StepLabel, StepProps as StepperStepProps, Typography } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { ReactChild, useMemo } from "react";
|
||||
import { StepIcon } from "@/components/stepIcon";
|
||||
|
||||
type StepProps = StepperStepProps & {
|
||||
notBefore?: Moment;
|
||||
until?: Moment;
|
||||
completedOn?: Moment;
|
||||
label: string;
|
||||
@ -17,7 +18,7 @@ type StepProps = StepperStepProps & {
|
||||
const now = moment();
|
||||
|
||||
export const Step = (props: StepProps) => {
|
||||
const { until, label, completedOn, children, completed = false, declined = false, waiting = false, state = null, ...rest } = props;
|
||||
const { until, notBefore, label, completedOn, children, completed = false, declined = false, waiting = false, state = null, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isLate = useMemo(() => until?.isBefore(completedOn || now), [completedOn, until]);
|
||||
@ -26,18 +27,21 @@ export const Step = (props: StepProps) => {
|
||||
return <StepperStep { ...rest } completed={ completed }>
|
||||
<StepLabel error={ declined } StepIconComponent={ StepIcon } StepIconProps={{ ...props, waiting } as any}>
|
||||
{ label }
|
||||
{ until && <Box>
|
||||
{ state && <>
|
||||
<Typography variant="subtitle2" display="inline">{ state }</Typography>
|
||||
<Typography variant="subtitle2" display="inline" color="textSecondary"> • </Typography>
|
||||
</> }
|
||||
<Typography variant="subtitle2" color="textSecondary" display="inline">
|
||||
{ t('until', { date: until }) }
|
||||
{ isLate && <Typography color="error" display="inline"
|
||||
variant="body2"> - { t('late', { by: moment.duration(now.diff(until)) }) }</Typography> }
|
||||
{ !isLate && !completed && <Typography display="inline" variant="body2"> - { t('left', { left: left }) }</Typography> }
|
||||
</Typography>
|
||||
</Box> }
|
||||
<Box>
|
||||
{ state && <Typography variant="subtitle2" display="inline">{ state }</Typography> }
|
||||
{ (notBefore || until) && <Typography variant="subtitle2" display="inline" color="textSecondary"> • </Typography> }
|
||||
{ notBefore &&
|
||||
<Typography variant="subtitle2" color="textSecondary" display="inline">
|
||||
{ t('not-before', { date: notBefore }) }
|
||||
</Typography> }
|
||||
{ until &&
|
||||
<Typography variant="subtitle2" color="textSecondary" display="inline">
|
||||
{ t('until', { date: until }) }
|
||||
{ isLate && <Typography color="error" display="inline"
|
||||
variant="body2"> - { t('late', { by: moment.duration(now.diff(until)) }) }</Typography> }
|
||||
{ !isLate && !completed && <Typography display="inline" variant="body2"> - { t('left', { left: left }) }</Typography> }
|
||||
</Typography> }
|
||||
</Box>
|
||||
</StepLabel>
|
||||
{ children && <StepContent>{ children }</StepContent> }
|
||||
</StepperStep>
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
|
||||
export type Identifier = string;
|
||||
|
||||
export interface Identifiable {
|
||||
@ -8,3 +10,7 @@ export type Multilingual<T> = {
|
||||
pl: T,
|
||||
en: T
|
||||
}
|
||||
export interface Stateful {
|
||||
comment: string;
|
||||
state: SubmissionStatus;
|
||||
}
|
||||
|
@ -5,5 +5,4 @@ import { Identifiable } from "./common";
|
||||
export interface Course extends Identifiable {
|
||||
name: string,
|
||||
desiredSemesters: Semester[],
|
||||
possibleProgramEntries: InternshipProgramEntry[];
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Moment } from "moment";
|
||||
import { Moment } from "moment-timezone";
|
||||
import { Course } from "@/data/course";
|
||||
import { Identifiable } from "@/data/common";
|
||||
import { InternshipProgramEntry, InternshipType } from "@/data/internship";
|
||||
import { ReportSchema } from "@/data/report";
|
||||
|
||||
export type Edition = {
|
||||
course: Course;
|
||||
@ -11,6 +13,9 @@ export type Edition = {
|
||||
reportingEnd: Moment,
|
||||
minimumInternshipHours: number;
|
||||
maximumInternshipHours?: number;
|
||||
program: InternshipProgramEntry[];
|
||||
types: InternshipType[];
|
||||
schema: ReportSchema;
|
||||
} & Identifiable
|
||||
|
||||
export type Deadlines = {
|
||||
|
@ -1,52 +1,13 @@
|
||||
import { Moment } from "moment";
|
||||
import { Identifiable } from "./common";
|
||||
import { Moment } from "moment-timezone";
|
||||
import { Identifiable, Multilingual } from "./common";
|
||||
import { Student } from "@/data/student";
|
||||
import { Company, Office } from "@/data/company";
|
||||
|
||||
export enum InternshipType {
|
||||
FreeInternship = "FreeInternship",
|
||||
GraduateInternship = "GraduateInternship",
|
||||
FreeApprenticeship = "FreeApprenticeship",
|
||||
PaidApprenticeship = "PaidApprenticeship",
|
||||
ForeignInternship = "ForeignInternship",
|
||||
UOP = "UOP",
|
||||
UD = "UD",
|
||||
UZ = "UZ",
|
||||
Other = "Other",
|
||||
}
|
||||
|
||||
export const internshipTypeLabels: { [type in InternshipType]: { label: string, description?: string } } = {
|
||||
[InternshipType.FreeInternship]: {
|
||||
label: "Umowa o organizację praktyki",
|
||||
description: "Praktyka bezpłatna"
|
||||
},
|
||||
[InternshipType.GraduateInternship]: {
|
||||
label: "Umowa o praktykę absolwencką"
|
||||
},
|
||||
[InternshipType.FreeApprenticeship]: {
|
||||
label: "Umowa o staż bezpłatny"
|
||||
},
|
||||
[InternshipType.PaidApprenticeship]: {
|
||||
label: "Umowa o staż płatny",
|
||||
description: "np. przemysłowy"
|
||||
},
|
||||
[InternshipType.ForeignInternship]: {
|
||||
label: "Praktyka zagraniczna",
|
||||
description: "np. IAESTE, ERASMUS"
|
||||
},
|
||||
[InternshipType.UOP]: {
|
||||
label: "Umowa o pracę"
|
||||
},
|
||||
[InternshipType.UD]: {
|
||||
label: "Umowa o dzieło (w tym B2B)"
|
||||
},
|
||||
[InternshipType.UZ]: {
|
||||
label: "Umowa o zlecenie (w tym B2B)"
|
||||
},
|
||||
[InternshipType.Other]: {
|
||||
label: "Inna",
|
||||
description: "Należy wprowadzić samodzielnie"
|
||||
},
|
||||
export interface InternshipType extends Identifiable {
|
||||
label: Multilingual<string>,
|
||||
description?: Multilingual<string>,
|
||||
requiresDeanApproval: boolean,
|
||||
requiresInsurance: boolean,
|
||||
}
|
||||
|
||||
export interface InternshipProgramEntry extends Identifiable {
|
||||
@ -67,10 +28,6 @@ export interface Internship extends Identifiable {
|
||||
office: Office;
|
||||
}
|
||||
|
||||
export interface Plan extends Identifiable {
|
||||
|
||||
}
|
||||
|
||||
export interface Mentor {
|
||||
name: string;
|
||||
surname: string;
|
||||
|
@ -5,3 +5,5 @@ export interface Page extends Identifiable {
|
||||
content: Multilingual<string>;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
41
src/data/report.ts
Normal file
41
src/data/report.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Identifiable, Multilingual, Stateful } from "@/data/common";
|
||||
|
||||
interface PredefinedChoices {
|
||||
choices: Multilingual<string>[];
|
||||
}
|
||||
|
||||
export interface BaseFieldDefinition extends Identifiable {
|
||||
description: Multilingual<string>;
|
||||
label: Multilingual<string>;
|
||||
}
|
||||
|
||||
export interface TextFieldDefinition extends BaseFieldDefinition {
|
||||
type: "short-text" | "long-text";
|
||||
}
|
||||
|
||||
export type TextFieldValue = string;
|
||||
|
||||
export interface MultiChoiceFieldDefinition extends BaseFieldDefinition, PredefinedChoices {
|
||||
type: "checkbox";
|
||||
}
|
||||
|
||||
export type MultiChoiceValue = Multilingual<string>[];
|
||||
|
||||
export interface SingleChoiceFieldDefinition extends BaseFieldDefinition, PredefinedChoices {
|
||||
type: "radio" | "select";
|
||||
}
|
||||
|
||||
export type SingleChoiceValue = Multilingual<string>;
|
||||
|
||||
export type ReportFieldDefinition = TextFieldDefinition | MultiChoiceFieldDefinition | SingleChoiceFieldDefinition;
|
||||
export type ReportFieldValue = TextFieldValue | MultiChoiceValue | SingleChoiceValue;
|
||||
export type ReportFieldValues = { [field: string]: ReportFieldValue };
|
||||
export type ReportSchema = ReportFieldDefinition[];
|
||||
export type ReportFieldType = ReportFieldDefinition['type'];
|
||||
|
||||
export interface Report extends Stateful, Identifiable {
|
||||
fields: ReportFieldValues;
|
||||
}
|
||||
|
||||
export const reportFieldTypes: ReportFieldType[] = ["short-text", "long-text", "checkbox", "radio", "select"];
|
||||
|
@ -23,6 +23,8 @@ export function getMissingStudentData(student: Student): (keyof Student)[] {
|
||||
!!student.email || "email",
|
||||
!!student.albumNumber || "albumNumber",
|
||||
!!student.semester || "semester",
|
||||
!!student.course || "course",
|
||||
// !!student.course || "course",
|
||||
].filter(x => x !== true) as (keyof Student)[];
|
||||
}
|
||||
|
||||
export const fullname = (student: Student) => `${ student.name } ${ student.surname }`;
|
||||
|
23
src/forms/ckeditor.tsx
Normal file
23
src/forms/ckeditor.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { FieldProps } from "formik";
|
||||
|
||||
// @ts-ignore
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
// @ts-ignore
|
||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import { FormControl, FormControlLabel, FormControlProps, FormLabel, TextFieldProps } from "@material-ui/core";
|
||||
import React from "react";
|
||||
|
||||
export type CKEditorFieldProps = FieldProps & FormControlProps & { label?: string };
|
||||
|
||||
export function CKEditorField({ field, form, error, label, ...props }: CKEditorFieldProps) {
|
||||
const handleChange = (_: unknown, editor: any) => {
|
||||
const data = editor.getData();
|
||||
form.setFieldValue(field.name, data);
|
||||
form.setFieldTouched(field.name);
|
||||
}
|
||||
|
||||
return <FormControl { ...props }>
|
||||
<FormLabel style={{ marginBottom: "0.5rem" }}>{ label }</FormLabel>
|
||||
<CKEditor data={ field.value } editor={ ClassicEditor } onChange={ handleChange }/>
|
||||
</FormControl>
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import React, { HTMLProps, useMemo } from "react";
|
||||
import React, { HTMLProps, useEffect, useMemo, useState } from "react";
|
||||
import { Company, formatAddress, Office } from "@/data";
|
||||
import { sampleCompanies } from "@/provider/dummy";
|
||||
import { Autocomplete } from "@material-ui/lab";
|
||||
import { Grid, TextField, Typography } from "@material-ui/core";
|
||||
import { InternshipFormValues } from "@/forms/internship";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Field, useFormikContext } from "formik";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui"
|
||||
import api from "@/api";
|
||||
|
||||
export const CompanyItem = ({ company, ...props }: { company: Company } & HTMLProps<any>) => (
|
||||
<div className="company-item" { ...props }>
|
||||
@ -27,9 +27,15 @@ export const BranchForm: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const disabled = useMemo(() => !values.companyName, [values.companyName]);
|
||||
const offices = useMemo(() => values.company?.offices || [], [values.company]);
|
||||
const [offices, setOffices] = useState<Office[]>([]);
|
||||
const canEdit = useMemo(() => !values.office && !disabled, [values.office, disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setOffices(values.company?.id ? (await api.companies.offices(values.company?.id)) : []);
|
||||
})()
|
||||
}, [ values.company?.id ])
|
||||
|
||||
const handleCityChange = (event: any, value: Office | string | null) => {
|
||||
if (typeof value === "string") {
|
||||
setValues({
|
||||
@ -97,7 +103,7 @@ export const BranchForm: React.FC = () => {
|
||||
onInputChange={ handleCityInput }
|
||||
onBlur={ ev => setFieldTouched("city", true) }
|
||||
inputValue={ values.city }
|
||||
value={ values.office ? values.office : null }
|
||||
value={ values.office ? values.office : values.city }
|
||||
freeSolo
|
||||
/>
|
||||
</Grid>
|
||||
@ -143,8 +149,23 @@ export const CompanyForm: React.FunctionComponent = () => {
|
||||
const { values, setValues, errors, touched, setFieldTouched } = useFormikContext<InternshipFormValues>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
|
||||
const canEdit = useMemo(() => !values.company, [values.company]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input || values.companyName == input) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setCompanies(await api.companies.search(input));
|
||||
})()
|
||||
|
||||
setValues({ ...values, company: null, companyName: input }, true)
|
||||
}, [ input ]);
|
||||
|
||||
const handleCompanyChange = (event: any, value: Company | string | null) => {
|
||||
setFieldTouched("companyName", true);
|
||||
|
||||
@ -173,14 +194,17 @@ export const CompanyForm: React.FunctionComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<Grid container>
|
||||
<Grid item>
|
||||
<Autocomplete options={ sampleCompanies }
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete options={ companies }
|
||||
getOptionLabel={ option => typeof option === "string" ? option : option.name }
|
||||
renderOption={ company => <CompanyItem company={ company }/> }
|
||||
renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.company-name") } fullWidth
|
||||
error={ touched.companyName && !!errors.companyName } helperText={ touched.companyName && errors.companyName }/> }
|
||||
onChange={ handleCompanyChange } value={ values.company || values.companyName }
|
||||
onChange={ handleCompanyChange }
|
||||
value={ values.company || values.companyName }
|
||||
inputValue={ input }
|
||||
freeSolo
|
||||
onInputChange={ (_, value) => setInput(value) }
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={ 4 }>
|
||||
|
@ -1,14 +1,26 @@
|
||||
import React, { HTMLProps, useMemo, useState } from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, Grid, TextField, Typography } from "@material-ui/core";
|
||||
import React, { HTMLProps, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
Grid,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@material-ui/core";
|
||||
import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers";
|
||||
import { CompanyForm } from "@/forms/company";
|
||||
import { StudentForm } from "@/forms/student";
|
||||
import { sampleStudent } from "@/provider/dummy/student";
|
||||
import { Company, Internship, InternshipType, internshipTypeLabels, Office, Student } from "@/data";
|
||||
import { Company, Internship, InternshipProgramEntry, InternshipType, Office, Student } from "@/data";
|
||||
import { Nullable } from "@/helpers";
|
||||
import moment, { Moment } from "moment";
|
||||
import { Moment } from "moment-timezone";
|
||||
import { computeWorkingHours } from "@/utils/date";
|
||||
import { Autocomplete } from "@material-ui/lab";
|
||||
import { Alert, AlertTitle, Autocomplete } from "@material-ui/lab";
|
||||
import { emptyInternship } from "@/provider/dummy/internship";
|
||||
import { InternshipProposalActions, useDispatch } from "@/state/actions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -22,8 +34,11 @@ import { Field, Form, Formik, useFormikContext } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { Transformer } from "@/serialization";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui"
|
||||
import { Edition } from "@/data/edition";
|
||||
import { useUpdateEffect } from "@/hooks";
|
||||
import { useCurrentEdition, useCurrentStudent, useInternshipTypes, useUpdateEffect } from "@/hooks";
|
||||
import { internshipRegistrationUpdateTransformer } from "@/api/dto/internship-registration";
|
||||
import api from "@/api";
|
||||
import FormLabel from "@material-ui/core/FormLabel";
|
||||
import { ValidationError, ValidationMessage } from "@/api/internship";
|
||||
|
||||
export type InternshipFormValues = {
|
||||
startDate: Moment | null;
|
||||
@ -42,6 +57,7 @@ export type InternshipFormValues = {
|
||||
mentorEmail: string;
|
||||
mentorPhone: string;
|
||||
kindOther: string | null;
|
||||
program: InternshipProgramEntry[];
|
||||
|
||||
// relations
|
||||
kind: InternshipType | null;
|
||||
@ -71,15 +87,14 @@ const emptyInternshipValues: InternshipFormValues = {
|
||||
startDate: null,
|
||||
student: sampleStudent,
|
||||
workingHours: 40,
|
||||
program: [],
|
||||
}
|
||||
|
||||
export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType } & HTMLProps<any>) => {
|
||||
const info = internshipTypeLabels[type];
|
||||
|
||||
export const InternshipTypeItem = ({ internshipType: type, ...props }: { internshipType: InternshipType } & HTMLProps<any>) => {
|
||||
return (
|
||||
<div className="internship=type-item" { ...props }>
|
||||
<div>{ info.label }</div>
|
||||
{ info.description && <Typography variant="caption">{ info.description }</Typography> }
|
||||
<div>{ type.label.pl }</div>
|
||||
{ type.description && <Typography variant="caption">{ type.description.pl }</Typography> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -87,25 +102,57 @@ export const InternshipTypeItem = ({ type, ...props }: { type: InternshipType }
|
||||
const InternshipProgramForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const { values, handleBlur, setFieldValue, errors } = useFormikContext<InternshipFormValues>();
|
||||
const [ selectedProgramEntries, setSelectedProgramEntries ] = useState<InternshipProgramEntry[]>(values.program);
|
||||
|
||||
const possibleProgramEntries = useSelector<AppState, InternshipProgramEntry[]>(state => state.edition.program);
|
||||
|
||||
const types = useInternshipTypes();
|
||||
|
||||
const handleProgramEntryChange = (entry: InternshipProgramEntry) => (ev: any) => {
|
||||
if (ev.target.checked) {
|
||||
setSelectedProgramEntries([ ...selectedProgramEntries, entry ]);
|
||||
} else {
|
||||
setSelectedProgramEntries(selectedProgramEntries.filter(cur => cur.id != entry.id));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue("program", selectedProgramEntries);
|
||||
}, [ selectedProgramEntries ])
|
||||
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item md={ 4 }>
|
||||
<Autocomplete renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.kind") } fullWidth error={ !!errors.kind } helperText={ errors.kind }/> }
|
||||
getOptionLabel={ (option: InternshipType) => internshipTypeLabels[option].label }
|
||||
renderOption={ (option: InternshipType) => <InternshipTypeItem type={ option }/> }
|
||||
options={ Object.values(InternshipType) as InternshipType[] }
|
||||
getOptionLabel={ (option: InternshipType) => option.label.pl }
|
||||
renderOption={ (option: InternshipType) => <InternshipTypeItem internshipType={ option }/> }
|
||||
options={ types }
|
||||
disableClearable
|
||||
value={ values.kind || undefined }
|
||||
value={ values.kind || null as any }
|
||||
onChange={ (_, value) => setFieldValue("kind", value) }
|
||||
onBlur={ handleBlur }
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={ 8 }>
|
||||
{
|
||||
values.kind === InternshipType.Other &&
|
||||
<Field label={ t("forms.internship.fields.kind-other") } name="kindOther" fullWidth component={ TextFieldFormik } />
|
||||
}
|
||||
{ values.kind?.requiresDeanApproval && <Grid item xs={ 12 }><Alert severity="warning">{ t("internship.kind-requires-dean-approval") }</Alert></Grid> }
|
||||
{/*<Grid item md={ 8 }>*/}
|
||||
{/* {*/}
|
||||
{/* values.kind === InternshipType.Other &&*/}
|
||||
{/* <Field label={ t("forms.internship.fields.kind-other") } name="kindOther" fullWidth component={ TextFieldFormik } />*/}
|
||||
{/* }*/}
|
||||
{/*</Grid>*/}
|
||||
<Grid item xs={ 12 }>
|
||||
<FormGroup>
|
||||
<FormLabel>{ t('forms.internship.fields.program', { count: 3 }) }</FormLabel>
|
||||
{ possibleProgramEntries.map(
|
||||
entry => <FormControlLabel
|
||||
control={ <Checkbox /> }
|
||||
checked={ selectedProgramEntries.find(cur => entry.id == cur.id) !== undefined }
|
||||
onChange={ handleProgramEntryChange(entry) }
|
||||
label={ entry.description }
|
||||
key={ entry.id }
|
||||
/>
|
||||
) }
|
||||
</FormGroup>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
@ -113,6 +160,7 @@ const InternshipProgramForm = () => {
|
||||
|
||||
const InternshipDurationForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const edition = useCurrentEdition();
|
||||
const {
|
||||
values: { startDate, endDate, workingHours },
|
||||
errors,
|
||||
@ -128,6 +176,8 @@ const InternshipDurationForm = () => {
|
||||
const hours = useMemo(() => overrideHours !== null ? overrideHours : computedHours || null, [overrideHours, computedHours]);
|
||||
const weeks = useMemo(() => hours !== null ? Math.floor(hours / workingHours) : null, [ hours ]);
|
||||
|
||||
const requiresDeanApproval = useMemo(() => edition?.startDate?.isAfter(startDate) || edition?.endDate?.isBefore(endDate), [ startDate, endDate ])
|
||||
|
||||
useUpdateEffect(() => {
|
||||
setFieldTouched("hours", true);
|
||||
setFieldValue("hours", hours, true);
|
||||
@ -136,21 +186,27 @@ const InternshipDurationForm = () => {
|
||||
return (
|
||||
<Grid container>
|
||||
<Grid item md={ 6 }>
|
||||
<DatePicker value={ startDate } onChange={ value => setFieldValue("startDate", value) }
|
||||
format="DD MMMM yyyy"
|
||||
<DatePicker value={ startDate }
|
||||
onChange={ value => setFieldValue("startDate", value) }
|
||||
format="DD.MM.yyyy"
|
||||
disableToolbar fullWidth
|
||||
variant="inline" label={ t("forms.internship.fields.start-date") }
|
||||
minDate={ moment() }
|
||||
variant="inline"
|
||||
label={ t("forms.internship.fields.start-date") }
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={ 6 }>
|
||||
<DatePicker value={ endDate } onChange={ value => setFieldValue("endDate", value) }
|
||||
format="DD MMMM yyyy"
|
||||
<DatePicker value={ endDate }
|
||||
onChange={ value => setFieldValue("endDate", value) }
|
||||
format="DD.MM.yyyy"
|
||||
disableToolbar fullWidth
|
||||
variant="inline" label={ t("forms.internship.fields.end-date") }
|
||||
minDate={ startDate || moment() }
|
||||
variant="inline"
|
||||
label={ t("forms.internship.fields.end-date") }
|
||||
minDate={ startDate }
|
||||
/>
|
||||
</Grid>
|
||||
{ requiresDeanApproval && <Grid item xs={ 12 }>
|
||||
<Alert severity="warning">{ t("internship.duration-requires-dean-approval") }</Alert>
|
||||
</Grid> }
|
||||
<Grid item md={ 4 }>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="workingHours"
|
||||
@ -206,6 +262,7 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
|
||||
mentorLastName: internship.mentor?.surname || "",
|
||||
mentorPhone: internship.mentor?.phone || "",
|
||||
workingHours: 40,
|
||||
program: internship.program || [],
|
||||
}
|
||||
},
|
||||
reverseTransform(form: InternshipFormValues, context: InternshipConverterContext): Nullable<Internship> {
|
||||
@ -233,29 +290,32 @@ const converter: Transformer<Nullable<Internship>, InternshipFormValues, Interns
|
||||
nip: form.companyNip,
|
||||
offices: [],
|
||||
},
|
||||
hours: form.hours as number,
|
||||
hours: form.hours ? form.hours : 0,
|
||||
type: form.kind as InternshipType,
|
||||
program: form.program,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const InternshipForm: React.FunctionComponent = () => {
|
||||
const student = useCurrentStudent();
|
||||
const history = useHistory();
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [errors, setErrors] = useState<ValidationMessage[]>([]);
|
||||
|
||||
const initialInternship = useSelector<AppState, Nullable<Internship>>(state => getInternshipProposal(state.proposal) || {
|
||||
...emptyInternship,
|
||||
office: null,
|
||||
company: null,
|
||||
mentor: null,
|
||||
intern: sampleStudent
|
||||
intern: student
|
||||
});
|
||||
|
||||
const edition = useSelector<AppState, Edition>(state => state.edition as Edition);
|
||||
|
||||
const edition = useCurrentEdition();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const validationSchema = Yup.object<Partial<InternshipFormValues>>({
|
||||
@ -268,7 +328,7 @@ export const InternshipForm: React.FunctionComponent = () => {
|
||||
.required(t("validation.required"))
|
||||
.matches(/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*$/, t("validation.phone")),
|
||||
hours: Yup.number()
|
||||
.min(edition.minimumInternshipHours, t("validation.internship.minimum-hours", { hours: edition.minimumInternshipHours })),
|
||||
.min(edition?.minimumInternshipHours || 0, t("validation.internship.minimum-hours", { hours: edition?.minimumInternshipHours || 0 })),
|
||||
companyName: Yup.string().when("company", {
|
||||
is: null,
|
||||
then: Yup.string().required(t("validation.required"))
|
||||
@ -283,33 +343,42 @@ export const InternshipForm: React.FunctionComponent = () => {
|
||||
city: Yup.string().required(t("validation.required")),
|
||||
postalCode: Yup.string().required(t("validation.required")),
|
||||
building: Yup.string().required(t("validation.required")),
|
||||
kindOther: Yup.string().when("kind", {
|
||||
is: (values: InternshipFormValues) => values?.kind === InternshipType.Other,
|
||||
then: Yup.string().required(t("validation.required"))
|
||||
})
|
||||
program: Yup.array() as any,
|
||||
// kindOther: Yup.string().when("kind", {
|
||||
// is: (values: InternshipFormValues) => values?.kind === InternshipType.Other,
|
||||
// then: Yup.string().required(t("validation.required"))
|
||||
// })
|
||||
})
|
||||
|
||||
const values = converter.transform(initialInternship);
|
||||
|
||||
const handleSubmit = (values: InternshipFormValues) => {
|
||||
const handleSubmit = async (values: InternshipFormValues) => {
|
||||
setConfirmDialogOpen(false);
|
||||
|
||||
dispatch({
|
||||
type: InternshipProposalActions.Send,
|
||||
internship: converter.reverseTransform(values, {
|
||||
internship: initialInternship as Internship,
|
||||
}) as Internship
|
||||
});
|
||||
const internship = converter.reverseTransform(values, { internship: initialInternship as Internship });
|
||||
const update = internshipRegistrationUpdateTransformer.transform(internship);
|
||||
|
||||
history.push(route("home"))
|
||||
try {
|
||||
await api.internship.update(update);
|
||||
dispatch({ type: InternshipProposalActions.Send });
|
||||
|
||||
history.push(route("home"))
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
setErrors(error.messages);
|
||||
root.current?.scrollIntoView({ behavior: "smooth" })
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const InnerForm = () => {
|
||||
const { submitForm, validateForm } = useFormikContext();
|
||||
|
||||
const handleSubmitConfirmation = async () => {
|
||||
const errors = await validateForm();
|
||||
|
||||
// const errors = await validateForm();
|
||||
const errors = {};
|
||||
if (Object.keys(errors).length == 0) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
@ -321,10 +390,16 @@ export const InternshipForm: React.FunctionComponent = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
}
|
||||
|
||||
return <Form>
|
||||
return <Form ref={ root as any }>
|
||||
{ errors.length > 0 && <Alert severity="warning">
|
||||
<AlertTitle>{ t('internship.validation.has-errors') }</AlertTitle>
|
||||
<ul style={{ paddingLeft: 0 }}>
|
||||
{ errors.map(message => <li key={ message.key }>{ t(`validation.api.${message.key}`, message.parameters) }</li>) }
|
||||
</ul>
|
||||
</Alert> }
|
||||
<Typography variant="h3" className="section-header">{ t('internship.sections.intern-info') }</Typography>
|
||||
<StudentForm />
|
||||
<Typography variant="h3" className="section-header">{ t('internship.sections.kind' )}</Typography>
|
||||
<Typography variant="h3" className="section-header">{ t('internship.sections.kind') }</Typography>
|
||||
<InternshipProgramForm />
|
||||
<Typography variant="h3" className="section-header">{ t('internship.sections.duration') }</Typography>
|
||||
<InternshipDurationForm />
|
||||
|
@ -5,37 +5,56 @@ import { Actions } from "@/components";
|
||||
import { Link as RouterLink, useHistory } from "react-router-dom";
|
||||
import { route } from "@/routing";
|
||||
import React, { useState } from "react";
|
||||
import { Plan } from "@/data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InternshipPlanActions, useDispatch } from "@/state/actions";
|
||||
import { UploadType } from "@/api/upload";
|
||||
import api from "@/api";
|
||||
import { useSelector } from "react-redux";
|
||||
import { AppState } from "@/state/reducer";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
|
||||
export const PlanForm = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [plan, setPlan] = useState<Plan>({});
|
||||
const [file, setFile] = useState<File>();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch({ type: InternshipPlanActions.Send, plan });
|
||||
history.push(route("home"))
|
||||
const document = useSelector<AppState>(state => state.plan.document);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let destination: InternshipDocument = document as any;
|
||||
|
||||
if (!destination) {
|
||||
destination = await api.upload.create(UploadType.Ipp);
|
||||
}
|
||||
|
||||
dispatch({ type: InternshipPlanActions.Send, document: destination });
|
||||
|
||||
await api.upload.upload(destination, file);
|
||||
|
||||
history.push("/");
|
||||
}
|
||||
|
||||
return <Grid container>
|
||||
<Grid item>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body1" component="p">{ t('forms.plan.instructions') }</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Grid item xs={12}>
|
||||
<Button href="https://eti.pg.edu.pl/documents/611675/100028367/indywidualny%20program%20praktyk" startIcon={ <DescriptionIcon /> }>
|
||||
{ t('steps.plan.template') }
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<DropzoneArea acceptedFiles={["image/*", "application/pdf"]} filesLimit={ 1 } dropzoneText={ t("dropzone") }/>
|
||||
<Grid item xs={12}>
|
||||
<DropzoneArea acceptedFiles={["image/*", "application/pdf"]} filesLimit={ 1 } dropzoneText={ t("dropzone") } onChange={ files => setFile(files[0]) }/>
|
||||
<FormHelperText>{ t('forms.plan.dropzone-help') }</FormHelperText>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Grid item xs={12}>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" onClick={ handleSubmit }>
|
||||
{ t('confirm') }
|
||||
|
186
src/forms/report.tsx
Normal file
186
src/forms/report.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import React, { useState } from "react";
|
||||
import { emptyReport, sampleReportSchema } from "@/provider/dummy/report";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Grid,
|
||||
Typography,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Radio,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem, FormHelperText
|
||||
} from "@material-ui/core";
|
||||
import { Actions } from "@/components";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { route } from "@/routing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MultiChoiceFieldDefinition, Report, ReportFieldDefinition, ReportFieldValues, SingleChoiceFieldDefinition } from "@/data/report";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui";
|
||||
import { Field, Form, Formik, useFormik, useFormikContext } from "formik";
|
||||
import { Multilingual } from "@/data";
|
||||
import { Transformer } from "@/serialization";
|
||||
import api from "@/api";
|
||||
import { useCurrentEdition } from "@/hooks";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { Description as DescriptionIcon } from "@material-ui/icons";
|
||||
import { DropzoneArea } from "material-ui-dropzone";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
import { UploadType } from "@/api/upload";
|
||||
import { InternshipPlanActions, InternshipReportActions, useDispatch } from "@/state/actions";
|
||||
import { useSelector } from "react-redux";
|
||||
import { AppState } from "@/state/reducer";
|
||||
|
||||
export type ReportFieldProps<TField = ReportFieldDefinition> = {
|
||||
field: TField;
|
||||
}
|
||||
|
||||
export const name = ({ id }: ReportFieldDefinition) => `field_${id}`;
|
||||
|
||||
export const CustomField = ({ field, ...props }: ReportFieldProps) => {
|
||||
switch (field.type) {
|
||||
case "short-text":
|
||||
case "long-text":
|
||||
return <CustomField.Text {...props} field={ field } />
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
return <CustomField.Choice {...props} field={ field }/>
|
||||
case "select":
|
||||
return <CustomField.Select {...props} field={ field }/>
|
||||
}
|
||||
}
|
||||
|
||||
CustomField.Text = ({ field }: ReportFieldProps) => {
|
||||
return <>
|
||||
<Field label={ field.label.pl } name={ name(field) }
|
||||
fullWidth
|
||||
rows={ field.type == "long-text" ? 4 : 1 }
|
||||
multiline={ field.type == "long-text" }
|
||||
component={ TextFieldFormik }
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
|
||||
</>
|
||||
}
|
||||
|
||||
CustomField.Select = ({ field }: ReportFieldProps<SingleChoiceFieldDefinition>) => {
|
||||
const { t } = useTranslation();
|
||||
const id = `custom-field-${field.id}`;
|
||||
const { values, setFieldValue } = useFormikContext<any>();
|
||||
|
||||
const value = values[name(field)];
|
||||
|
||||
return <FormControl variant="outlined">
|
||||
<InputLabel htmlFor={id}>{ field.label.pl }</InputLabel>
|
||||
<Select label={ field.label.pl } name={ name(field) } id={id} value={ value } onChange={ ({ target }) => setFieldValue(name(field), target.value, false) }>
|
||||
{ field.choices.map(choice => <MenuItem value={ choice as any }>{ choice.pl }</MenuItem>) }
|
||||
</Select>
|
||||
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
|
||||
</FormControl>
|
||||
}
|
||||
|
||||
CustomField.Choice = ({ field }: ReportFieldProps<SingleChoiceFieldDefinition | MultiChoiceFieldDefinition>) => {
|
||||
const { t } = useTranslation();
|
||||
const { values, setFieldValue } = useFormikContext<any>();
|
||||
|
||||
const value = values[name(field)];
|
||||
|
||||
const isSelected = field.type == 'radio'
|
||||
? (checked: Multilingual<string>) => value == checked
|
||||
: (checked: Multilingual<string>) => (value || []).includes(checked)
|
||||
|
||||
const handleChange = field.type == 'radio'
|
||||
? (choice: Multilingual<string>) => () => setFieldValue(name(field), choice, false)
|
||||
: (choice: Multilingual<string>) => () => {
|
||||
const current = value || [];
|
||||
setFieldValue(name(field), !current.includes(choice) ? [ ...current, choice ] : current.filter((c: Multilingual<string>) => c != choice), false);
|
||||
}
|
||||
|
||||
const Component = field.type == 'radio' ? Radio : Checkbox;
|
||||
|
||||
return <FormControl component="fieldset">
|
||||
<FormLabel component="legend">{ field.label.pl }</FormLabel>
|
||||
<FormGroup>
|
||||
{ field.choices.map(choice => <FormControlLabel
|
||||
control={ <Component checked={ isSelected(choice) } onChange={ handleChange(choice) }/> }
|
||||
label={ choice.pl }
|
||||
/>) }
|
||||
</FormGroup>
|
||||
<Typography variant="caption" color="textSecondary" dangerouslySetInnerHTML={{ __html: field.description.pl }}/>
|
||||
</FormControl>
|
||||
}
|
||||
|
||||
export type ReportFormValues = ReportFieldValues;
|
||||
|
||||
const reportFormValuesTransformer: Transformer<Report, ReportFormValues, { report: Report }> = {
|
||||
reverseTransform(subject: ReportFormValues, context: { report: Report }): Report {
|
||||
return { ...context.report, fields: subject };
|
||||
},
|
||||
transform(subject: Report, context: undefined): ReportFormValues {
|
||||
return subject.fields;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReportForm() {
|
||||
const edition = useCurrentEdition() as Edition;
|
||||
const report = emptyReport;
|
||||
const schema = edition.schema;
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File>();
|
||||
const document = useSelector<AppState>(state => state.report.evaluation);
|
||||
|
||||
const handleSubmit = async (values: ReportFormValues) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = reportFormValuesTransformer.reverseTransform(values, { report });
|
||||
await api.report.save(result);
|
||||
|
||||
let destination: InternshipDocument = document as any;
|
||||
|
||||
if (!destination) {
|
||||
destination = await api.upload.create(UploadType.InternshipEvaluation);
|
||||
}
|
||||
|
||||
await api.upload.upload(destination, file);
|
||||
};
|
||||
|
||||
return <Formik initialValues={ reportFormValuesTransformer.transform(report) } onSubmit={ handleSubmit }>
|
||||
{ ({ submitForm }) => <Form>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body1" component="p">{ t('forms.report.instructions') }</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button href="https://eti.pg.edu.pl/documents/611675/100028367/karta%20oceny%20praktyki" startIcon={ <DescriptionIcon /> }>
|
||||
{ t('steps.report.template') }
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<DropzoneArea acceptedFiles={["image/*", "application/pdf"]} filesLimit={ 1 } dropzoneText={ t("dropzone") } onChange={ files => setFile(files[0]) }/>
|
||||
<FormHelperText>{ t('forms.report.dropzone-help') }</FormHelperText>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h3">{ t('forms.report.report') }</Typography>
|
||||
</Grid>
|
||||
{ schema.map(field => <Grid item xs={12}><CustomField field={ field }/></Grid>) }
|
||||
<Grid item xs={12}>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" onClick={ submitForm }>
|
||||
{ t('confirm') }
|
||||
</Button>
|
||||
|
||||
<Button component={ RouterLink } to={ route("home") }>
|
||||
{ t('go-back') }
|
||||
</Button>
|
||||
</Actions>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Form> }
|
||||
</Formik>
|
||||
}
|
||||
|
@ -2,14 +2,16 @@ import { Course } from "@/data";
|
||||
import { Button, Grid, TextField } from "@material-ui/core";
|
||||
import { Alert, Autocomplete } from "@material-ui/lab";
|
||||
import React from "react";
|
||||
import { sampleCourse } from "@/provider/dummy/student";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormikContext } from "formik";
|
||||
import { InternshipFormValues } from "@/forms/internship";
|
||||
import { useCurrentEdition } from "@/hooks";
|
||||
import { ContactAction } from "@/components/contact";
|
||||
|
||||
export const StudentForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const { values: { student } } = useFormikContext<InternshipFormValues>();
|
||||
const course = useCurrentEdition()?.course as Course;
|
||||
|
||||
return <>
|
||||
<Grid container>
|
||||
@ -26,17 +28,19 @@ export const StudentForm = () => {
|
||||
<Autocomplete
|
||||
getOptionLabel={ (course: Course) => course.name }
|
||||
renderInput={ props => <TextField { ...props } label={ t("forms.internship.fields.course") } fullWidth/> }
|
||||
options={[ sampleCourse ]}
|
||||
value={ student.course }
|
||||
options={[ course ]}
|
||||
value={ course }
|
||||
disabled
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={3}>
|
||||
<TextField label={ t("forms.internship.fields.semester") } value={ student.semester } disabled fullWidth/>
|
||||
<TextField label={ t("forms.internship.fields.semester") } value={ student.semester || "" } disabled fullWidth/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Alert severity="warning" action={ <Button color="inherit" size="small">skontaktuj się z opiekunem</Button> }>
|
||||
Powyższe dane nie są poprawne?
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="warning" action={ <ContactAction>{
|
||||
({ action }) => <Button color="inherit" size="small" onClick={ action }>{ t("contact") }</Button>
|
||||
}</ContactAction> }>
|
||||
{ t("incorrect-data-question") }
|
||||
</Alert>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
138
src/forms/user.tsx
Normal file
138
src/forms/user.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { Student } from "@/data";
|
||||
import { Transformer } from "@/serialization";
|
||||
import React, { useMemo } from "react";
|
||||
import { Field, Formik, useFormikContext } from "formik";
|
||||
import api from "@/api";
|
||||
import { Button, Grid, Typography } from "@material-ui/core";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Actions } from "@/components";
|
||||
import { Nullable } from "@/helpers";
|
||||
import * as Yup from "yup";
|
||||
import { StudentActions, useDispatch } from "@/state/actions";
|
||||
import { route } from "@/routing";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
interface StudentFormValues {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
albumNumber: number | "";
|
||||
semester: number | "";
|
||||
}
|
||||
|
||||
type StudentFormProps = {
|
||||
student: Student;
|
||||
}
|
||||
|
||||
const studentToFormValuesTransformer: Transformer<Nullable<Student>, StudentFormValues, { current: Student }> = {
|
||||
transform(subject: Nullable<Student>, context: { current: Student }): StudentFormValues {
|
||||
return {
|
||||
firstName: subject.name || "",
|
||||
lastName: subject.surname || "",
|
||||
albumNumber: subject.albumNumber || "",
|
||||
semester: subject.semester || "",
|
||||
email: subject.email || "",
|
||||
};
|
||||
},
|
||||
reverseTransform(subject: StudentFormValues, { current }: { current: Student }): Nullable<Student> {
|
||||
return {
|
||||
...current,
|
||||
name: subject.firstName,
|
||||
surname: subject.lastName,
|
||||
albumNumber: subject.albumNumber ? subject.albumNumber : null,
|
||||
semester: subject.semester ? subject.semester : null,
|
||||
email: subject.email,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const StudentForm = ({ student }: StudentFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const validationSchema = useMemo(() => Yup.object<StudentFormValues>({
|
||||
semester: Yup.number().required().min(1).max(10),
|
||||
albumNumber: Yup.number().required(),
|
||||
email: Yup.string().required(),
|
||||
firstName: Yup.string().required(),
|
||||
lastName: Yup.string().required(),
|
||||
}), []);
|
||||
|
||||
const initialValues: StudentFormValues = useMemo(
|
||||
() => studentToFormValuesTransformer.transform(student, { current: student }),
|
||||
[ student ]
|
||||
)
|
||||
|
||||
|
||||
const handleFormSubmit = async (values: StudentFormValues) => {
|
||||
const update = studentToFormValuesTransformer.reverseTransform(values, { current: student }) as Student;
|
||||
const updated = await api.student.update(update);
|
||||
|
||||
dispatch({
|
||||
type: StudentActions.Set,
|
||||
student: updated,
|
||||
})
|
||||
|
||||
history.push(route("home"));
|
||||
}
|
||||
|
||||
|
||||
const InnerForm = () => {
|
||||
const { handleSubmit } = useFormikContext();
|
||||
|
||||
return <form onSubmit={ handleSubmit }>
|
||||
<Typography variant="subtitle1">{ t("forms.student.sections.personal") }</Typography>
|
||||
<Grid container>
|
||||
<Grid item md={ 6 }>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="firstName"
|
||||
label={ t("forms.student.fields.first-name") }
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={ 6 }>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="lastName"
|
||||
label={ t("forms.student.fields.last-name") }
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="email"
|
||||
label={ t("forms.student.fields.email") }
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="subtitle1">{ t("forms.student.sections.studies")}</Typography>
|
||||
<Grid container>
|
||||
<Grid item md={ 6 }>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="albumNumber"
|
||||
label={ t("forms.student.fields.album-number") }
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item md={ 6 }>
|
||||
<Field component={ TextFieldFormik }
|
||||
name="semester"
|
||||
label={ t("forms.student.fields.semester") }
|
||||
fullWidth
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Actions>
|
||||
<Button variant="contained" type="submit" color="primary">{ t("save") }</Button>
|
||||
</Actions>
|
||||
</form>
|
||||
}
|
||||
|
||||
return <Formik initialValues={ initialValues } onSubmit={ handleFormSubmit } validationSchema={ validationSchema }>
|
||||
<InnerForm />
|
||||
</Formik>
|
||||
}
|
||||
|
||||
export default StudentForm;
|
@ -2,6 +2,7 @@ export type Nullable<T> = { [P in keyof T]: T[P] | null }
|
||||
|
||||
export type Subset<T> = { [K in keyof T]?: Subset<T[K]> }
|
||||
export type Dictionary<T> = { [key: string]: T };
|
||||
export type OneOrMany<T> = T | T[];
|
||||
|
||||
export type Index = string | symbol | number;
|
||||
|
||||
@ -12,3 +13,37 @@ export interface DOMEvent<TTarget extends EventTarget> extends Event {
|
||||
export function delay(time: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
export function throttle<TArgs extends any[]>(decorated: (...args: TArgs) => void, time: number = 150) {
|
||||
let timeout: number | undefined;
|
||||
return function (this: any, ...args: TArgs): void {
|
||||
if (typeof timeout !== 'undefined') {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = window.setTimeout(() => {
|
||||
timeout = undefined;
|
||||
decorated.call(this, ...args);
|
||||
}, time);
|
||||
}
|
||||
}
|
||||
|
||||
export function encapsulate<T>(value: OneOrMany<T>): T[] {
|
||||
if (value instanceof Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return [ value ];
|
||||
}
|
||||
|
||||
export function one<T>(value: OneOrMany<T>): T {
|
||||
if (value instanceof Array) {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from "./useProxyState"
|
||||
export * from "./useUpdateEffect"
|
||||
export * from "./useAsync"
|
||||
export * from "./state"
|
||||
export * from "./providers"
|
||||
|
15
src/hooks/providers.ts
Normal file
15
src/hooks/providers.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "@/api";
|
||||
import { InternshipType } from "@/data";
|
||||
|
||||
export const useInternshipTypes = () => {
|
||||
const [types, setTypes] = useState<InternshipType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setTypes(await api.type.available());
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return types;
|
||||
}
|
23
src/hooks/state.ts
Normal file
23
src/hooks/state.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { AppState } from "@/state/reducer";
|
||||
import { Edition, getEditionDeadlines } from "@/data/edition";
|
||||
import { editionSerializationTransformer } from "@/serialization";
|
||||
import { Student } from "@/data";
|
||||
import { UserState } from "@/state/reducer/user";
|
||||
|
||||
export const useCurrentStudent = () => useSelector<AppState, Student | null>(
|
||||
state => state.student
|
||||
)
|
||||
|
||||
export const useCurrentEdition = () => useSelector<AppState, Edition | null>(
|
||||
state => state.edition?.edition && editionSerializationTransformer.reverseTransform(state.edition.edition)
|
||||
)
|
||||
|
||||
export const useDeadlines = () => {
|
||||
const edition = useCurrentEdition() as Edition;
|
||||
return getEditionDeadlines(edition);
|
||||
}
|
||||
|
||||
export const useCurrentUser = () => useSelector<AppState, UserState>(
|
||||
state => state.user
|
||||
)
|
@ -19,7 +19,6 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setValue(undefined);
|
||||
|
||||
const myMagicNumber = semaphore.value + 1;
|
||||
semaphore.value = myMagicNumber;
|
||||
@ -30,6 +29,8 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
|
||||
setLoading(false);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(error)
|
||||
|
||||
if (semaphore.value == myMagicNumber) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
@ -40,8 +41,10 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
|
||||
useEffect(() => {
|
||||
if (typeof supplier === "function") {
|
||||
setPromise(supplier());
|
||||
} else {
|
||||
setPromise(supplier);
|
||||
}
|
||||
}, [])
|
||||
}, [ supplier ])
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@ -50,9 +53,9 @@ export function useAsync<T, TError = any>(supplier: Promise<T> | (() => Promise<
|
||||
};
|
||||
}
|
||||
|
||||
export function useAsyncState<T, TError = any>(initial: Promise<T> | undefined): AsyncState<T, TError> {
|
||||
export function useAsyncState<T, TError = any>(initial?: Promise<T> | undefined): AsyncState<T, TError> {
|
||||
const [promise, setPromise] = useState<Promise<T> | undefined>(initial);
|
||||
const asyncState = useAsync(promise);
|
||||
const asyncState = useAsync<T, TError>(promise);
|
||||
|
||||
return [ asyncState, setPromise ];
|
||||
}
|
||||
|
10
src/hooks/useDebouncedEffect.ts
Normal file
10
src/hooks/useDebouncedEffect.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { DependencyList, EffectCallback, useCallback, useEffect } from "react";
|
||||
|
||||
export function useDebouncedEffect(effect: EffectCallback, deps: DependencyList, time: number = 150) {
|
||||
const callback = useCallback(effect, deps);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => callback(), time);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [ callback, time ])
|
||||
}
|
11
src/i18n.ts
11
src/i18n.ts
@ -4,15 +4,18 @@ import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import "moment/locale/pl"
|
||||
import "moment/locale/en-gb"
|
||||
import moment, { isDuration, isMoment, unitOfTime } from "moment";
|
||||
import moment, { isDuration, isMoment, Moment, unitOfTime } from "moment-timezone";
|
||||
import { convertToRoman } from "@/utils/numbers";
|
||||
import MomentUtils from "@date-io/moment";
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: require('../translations/en.yaml'),
|
||||
management: require('../translations/management.en.yaml'),
|
||||
},
|
||||
pl: {
|
||||
translation: require('../translations/pl.yaml'),
|
||||
management: require('../translations/management.pl.yaml'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,4 +53,10 @@ i18n
|
||||
document.documentElement.lang = i18n.language;
|
||||
moment.locale(i18n.language)
|
||||
|
||||
export class LocalizedMomentUtils extends MomentUtils {
|
||||
getDatePickerHeaderText(date: Moment): string {
|
||||
return this.format(date, "d MMM yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
@ -7,16 +7,10 @@ import store, { persistor } from "@/state/store";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { MuiThemeProvider as ThemeProvider, StylesProvider } from "@material-ui/core/styles";
|
||||
import { MuiPickersUtilsProvider } from "@material-ui/pickers";
|
||||
import moment, { Moment } from "moment";
|
||||
import moment, { Moment } from "moment-timezone";
|
||||
import { studentTheme } from "@/ui/theme";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import MomentUtils from "@date-io/moment";
|
||||
|
||||
class LocalizedMomentUtils extends MomentUtils {
|
||||
getDatePickerHeaderText(date: Moment): string {
|
||||
return this.format(date, "d MMM yyyy");
|
||||
}
|
||||
}
|
||||
import { LocalizedMomentUtils } from "@/i18n";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
|
28
src/management/api/course.ts
Normal file
28
src/management/api/course.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Course } from "@/data";
|
||||
import { axios } from "@/api";
|
||||
import { CourseDTO, courseDtoTransformer } from "@/api/dto/course";
|
||||
import { encapsulate, OneOrMany } from "@/helpers";
|
||||
import { prepare } from "@/routing";
|
||||
|
||||
const COURSE_INDEX_ENDPOINT = '/management/course'
|
||||
const COURSE_ENDPOINT = COURSE_INDEX_ENDPOINT + "/:id";
|
||||
|
||||
export async function all(): Promise<Course[]> {
|
||||
const response = await axios.get<CourseDTO[]>(COURSE_INDEX_ENDPOINT);
|
||||
return response.data.map(dto => courseDtoTransformer.transform(dto))
|
||||
}
|
||||
|
||||
export async function remove(type: OneOrMany<Course>): Promise<void> {
|
||||
await Promise.all(encapsulate(type).map(
|
||||
type => axios.delete(prepare(COURSE_ENDPOINT, { id: type.id as string }))
|
||||
));
|
||||
}
|
||||
|
||||
export async function save(type: Course): Promise<Course> {
|
||||
await axios.put<Course>(
|
||||
COURSE_INDEX_ENDPOINT,
|
||||
courseDtoTransformer.reverseTransform(type)
|
||||
);
|
||||
|
||||
return type;
|
||||
}
|
27
src/management/api/document.ts
Normal file
27
src/management/api/document.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { encapsulate, OneOrMany } from "@/helpers";
|
||||
import { axios } from "@/api";
|
||||
import { prepare } from "@/routing";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
|
||||
const DOCUMENT_ACCEPT_ENDPOINT = "/management/document/:id/accept";
|
||||
const DOCUMENT_REJECT_ENDPOINT = "/management/document/:id/reject";
|
||||
|
||||
export async function accept(document: OneOrMany<InternshipDocument>, comment?: string): Promise<void> {
|
||||
const documents = encapsulate(document)
|
||||
|
||||
await Promise.all(documents.map(document => axios.put(
|
||||
prepare(DOCUMENT_ACCEPT_ENDPOINT, { id: document.id || ""}),
|
||||
JSON.stringify(comment || ""),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
||||
|
||||
export async function discard(document: OneOrMany<InternshipDocument>, comment: string): Promise<void> {
|
||||
const documents = encapsulate(document)
|
||||
|
||||
await Promise.all(documents.map(document => axios.put(
|
||||
prepare(DOCUMENT_REJECT_ENDPOINT, { id: document.id || ""}),
|
||||
JSON.stringify(comment),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
26
src/management/api/edition.ts
Normal file
26
src/management/api/edition.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { axios } from "@/api";
|
||||
import { EditionDTO, editionDtoTransformer, editionUpdateDtoTransformer } from "@/api/dto/edition";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { prepare } from "@/routing";
|
||||
|
||||
const MANAGEMENT_EDITION_INDEX_ENDPOINT = '/management/editions';
|
||||
const MANAGEMENT_EDITION_ENDPOINT = '/management/editions/:edition';
|
||||
|
||||
export async function all(): Promise<Edition[]> {
|
||||
const response = await axios.get<EditionDTO[]>(MANAGEMENT_EDITION_INDEX_ENDPOINT);
|
||||
return response.data.map(dto => editionDtoTransformer.transform(dto));
|
||||
}
|
||||
|
||||
export async function details(edition: string): Promise<Edition> {
|
||||
const response = await axios.get<EditionDTO>(prepare(MANAGEMENT_EDITION_ENDPOINT, { edition }));
|
||||
return editionDtoTransformer.transform(response.data);
|
||||
}
|
||||
|
||||
export async function save(edition: Edition): Promise<boolean> {
|
||||
const response = await axios.put<EditionDTO>(
|
||||
MANAGEMENT_EDITION_INDEX_ENDPOINT,
|
||||
editionUpdateDtoTransformer.transform(edition),
|
||||
);
|
||||
|
||||
return response.status == 200;
|
||||
}
|
14
src/management/api/field.ts
Normal file
14
src/management/api/field.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ReportFieldDefinition } from "@/data/report";
|
||||
import { axios } from "@/api";
|
||||
import { FieldDefinitionDTO, fieldDefinitionDtoTransformer } from "@/api/dto/edition";
|
||||
|
||||
const REPORT_FIELD_INDEX_ENDPOINT = "/management/report/fields"
|
||||
|
||||
export async function all(): Promise<ReportFieldDefinition[]> {
|
||||
const result = await axios.get<FieldDefinitionDTO[]>(REPORT_FIELD_INDEX_ENDPOINT);
|
||||
return (result.data || []).map(field => fieldDefinitionDtoTransformer.transform(field));
|
||||
}
|
||||
|
||||
export async function save(field: ReportFieldDefinition) {
|
||||
await axios.post(REPORT_FIELD_INDEX_ENDPOINT, fieldDefinitionDtoTransformer.reverseTransform(field));
|
||||
}
|
21
src/management/api/index.ts
Normal file
21
src/management/api/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import * as course from "./course"
|
||||
import * as edition from "./edition"
|
||||
import * as page from "./page"
|
||||
import * as type from "./type"
|
||||
import * as internship from "./internship"
|
||||
import * as document from "./document"
|
||||
import * as field from "./field"
|
||||
import * as report from "./report"
|
||||
|
||||
export const api = {
|
||||
course,
|
||||
edition,
|
||||
page,
|
||||
type,
|
||||
internship,
|
||||
document,
|
||||
field,
|
||||
report
|
||||
}
|
||||
|
||||
export default api;
|
110
src/management/api/internship.ts
Normal file
110
src/management/api/internship.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { Identifiable, Identifier, Internship } from "@/data";
|
||||
import moment, { Moment } from "moment-timezone";
|
||||
import { encapsulate, Nullable, OneOrMany } from "@/helpers";
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
import { axios } from "@/api";
|
||||
import { prepare, query } from "@/routing";
|
||||
import {
|
||||
InternshipDocument,
|
||||
InternshipDocumentDTO,
|
||||
internshipDocumentDtoTransformer,
|
||||
InternshipInfoDTO, internshipReportDtoTransformer,
|
||||
submissionStateDtoTransformer
|
||||
} from "@/api/dto/internship-registration";
|
||||
import { OneWayTransformer, Transformer } from "@/serialization";
|
||||
import { mentorDtoTransformer } from "@/api/dto/mentor";
|
||||
import { internshipTypeDtoTransformer } from "@/api/dto/type";
|
||||
import { studentDtoTransfer } from "@/api/dto/student";
|
||||
import { programEntryDtoTransformer } from "@/api/dto/edition";
|
||||
import { UploadType } from "@/api/upload";
|
||||
import { Report } from "@/data/report";
|
||||
|
||||
export type InternshipSubmission = Nullable<Internship> & {
|
||||
state: SubmissionStatus,
|
||||
changed: Moment | null,
|
||||
ipp: InternshipDocument | null,
|
||||
report: Report | null,
|
||||
evaluation: InternshipDocument,
|
||||
grade: number | null,
|
||||
approvals: InternshipDocument[],
|
||||
}
|
||||
|
||||
const INTERNSHIP_MANAGEMENT_INDEX_ENDPOINT = "/management/internship";
|
||||
const INTERNSHIP_MANAGEMENT_ENDPOINT = "/management/internship/:id";
|
||||
const INTERNSHIP_GRADE_ENDPOINT = "/management/internship/:id/grade";
|
||||
const INTERNSHIP_ACCEPT_ENDPOINT = "/management/internship/:id/registration/accept";
|
||||
const INTERNSHIP_REJECT_ENDPOINT = "/management/internship/:id/registration/reject";
|
||||
|
||||
const internshipInfoDtoTransformer: OneWayTransformer<InternshipInfoDTO, InternshipSubmission> = {
|
||||
transform(subject: InternshipInfoDTO, context?: never): InternshipSubmission {
|
||||
// @ts-ignore
|
||||
const ipp = subject.documentation.find<InternshipDocumentDTO>(doc => doc.type === UploadType.Ipp);
|
||||
// @ts-ignore
|
||||
const evaluation = subject.documentation.find<InternshipDocumentDTO>(doc => doc.type === UploadType.InternshipEvaluation);
|
||||
const report = subject.report;
|
||||
|
||||
return {
|
||||
...subject,
|
||||
changed: moment(subject.internshipRegistration.submissionDate),
|
||||
company: subject.internshipRegistration.company,
|
||||
startDate: moment(subject.internshipRegistration.start),
|
||||
endDate: moment(subject.internshipRegistration.end),
|
||||
hours: subject.internshipRegistration.declaredHours,
|
||||
id: subject.id,
|
||||
intern: subject.student && studentDtoTransfer.transform(subject.student),
|
||||
isAccepted: false,
|
||||
lengthInWeeks: 0,
|
||||
mentor: subject.internshipRegistration.mentor && mentorDtoTransformer.transform(subject.internshipRegistration.mentor),
|
||||
office: subject.internshipRegistration.branchAddress,
|
||||
program: (subject.internshipRegistration.subjects || []).map(subject => programEntryDtoTransformer.transform(subject.subject)),
|
||||
state: submissionStateDtoTransformer.transform(subject.internshipRegistration.state),
|
||||
type: subject.internshipRegistration.type && internshipTypeDtoTransformer.transform(subject.internshipRegistration.type),
|
||||
ipp: ipp && internshipDocumentDtoTransformer.transform(ipp),
|
||||
report: report && internshipReportDtoTransformer.transform(report),
|
||||
approvals: (subject.documentation.filter(doc => doc.type === UploadType.DeanConsent).map(subject => internshipDocumentDtoTransformer.transform(subject))),
|
||||
evaluation: evaluation && internshipDocumentDtoTransformer.transform(evaluation),
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export async function all(edition: Identifiable): Promise<InternshipSubmission[]> {
|
||||
const result = await axios.get<InternshipInfoDTO[]>(query(INTERNSHIP_MANAGEMENT_INDEX_ENDPOINT, { EditionId: edition.id || "" }));
|
||||
|
||||
return result.data.map(result => internshipInfoDtoTransformer.transform(result))
|
||||
}
|
||||
|
||||
export async function get(id: Identifier): Promise<InternshipSubmission> {
|
||||
const result = await axios.get<InternshipInfoDTO>(prepare(INTERNSHIP_MANAGEMENT_ENDPOINT, { id }))
|
||||
|
||||
return internshipInfoDtoTransformer.transform(result.data);
|
||||
}
|
||||
|
||||
export async function accept(internship: OneOrMany<Internship>, comment?: string): Promise<void> {
|
||||
const internships = encapsulate(internship)
|
||||
|
||||
await Promise.all(internships.map(internship => axios.put(
|
||||
prepare(INTERNSHIP_ACCEPT_ENDPOINT, { id: internship.id || ""}),
|
||||
JSON.stringify(comment || ""),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
||||
|
||||
export async function discard(internship: OneOrMany<Internship>, comment: string): Promise<void> {
|
||||
const internships = encapsulate(internship)
|
||||
|
||||
await Promise.all(internships.map(internship => axios.put(
|
||||
prepare(INTERNSHIP_REJECT_ENDPOINT, { id: internship.id || ""}),
|
||||
JSON.stringify(comment),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
||||
|
||||
export async function grade(internship: OneOrMany<Internship>, grade: number): Promise<void> {
|
||||
const internships = encapsulate(internship)
|
||||
|
||||
await Promise.all(internships.map(internship => axios.put(
|
||||
prepare(INTERNSHIP_GRADE_ENDPOINT, { id: internship.id || ""}),
|
||||
JSON.stringify(grade),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
30
src/management/api/page.ts
Normal file
30
src/management/api/page.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Page } from "@/data/page";
|
||||
import pageDtoTransformer, { PageDTO } from "@/api/dto/page";
|
||||
import { axios } from "@/api";
|
||||
import { STATIC_PAGE_ENDPOINT } from "@/api/page";
|
||||
import { prepare } from "@/routing";
|
||||
import { encapsulate, OneOrMany } from "@/helpers";
|
||||
|
||||
const STATIC_PAGE_INDEX_ENDPOINT = "/staticPage";
|
||||
|
||||
export { get, STATIC_PAGE_ENDPOINT } from "@/api/page"
|
||||
|
||||
export async function all(): Promise<Page[]> {
|
||||
const response = await axios.get<PageDTO[]>(STATIC_PAGE_INDEX_ENDPOINT);
|
||||
return response.data.map(dto => pageDtoTransformer.transform(dto));
|
||||
}
|
||||
|
||||
export async function remove(page: OneOrMany<Pick<Page, "slug">>): Promise<void> {
|
||||
const pages = encapsulate(page);
|
||||
|
||||
await Promise.all(pages.map(page => axios.delete(prepare(STATIC_PAGE_ENDPOINT, { slug: page.slug }))));
|
||||
}
|
||||
|
||||
export async function save(page: Page): Promise<Page> {
|
||||
const response = await axios.put<PageDTO>(
|
||||
STATIC_PAGE_INDEX_ENDPOINT,
|
||||
pageDtoTransformer.reverseTransform(page),
|
||||
);
|
||||
|
||||
return pageDtoTransformer.transform(response.data);
|
||||
}
|
27
src/management/api/report.ts
Normal file
27
src/management/api/report.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { encapsulate, OneOrMany } from "@/helpers";
|
||||
import { axios } from "@/api";
|
||||
import { prepare } from "@/routing";
|
||||
import { Report } from "@/data/report";
|
||||
|
||||
const REPORT_ACCEPT_ENDPOINT = "/management/report/:id/accept";
|
||||
const REPORT_REJECT_ENDPOINT = "/management/report/:id/reject";
|
||||
|
||||
export async function accept(document: OneOrMany<Report>, comment?: string): Promise<void> {
|
||||
const documents = encapsulate(document)
|
||||
|
||||
await Promise.all(documents.map(document => axios.put(
|
||||
prepare(REPORT_ACCEPT_ENDPOINT, { id: document.id || ""}),
|
||||
JSON.stringify(comment),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
||||
|
||||
export async function discard(document: OneOrMany<Report>, comment: string): Promise<void> {
|
||||
const documents = encapsulate(document)
|
||||
|
||||
await Promise.all(documents.map(document => axios.put(
|
||||
prepare(REPORT_REJECT_ENDPOINT, { id: document.id || ""}),
|
||||
JSON.stringify(comment),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)))
|
||||
}
|
28
src/management/api/type.ts
Normal file
28
src/management/api/type.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { InternshipType } from "@/data";
|
||||
import { axios } from "@/api";
|
||||
import { InternshipTypeDTO, internshipTypeDtoTransformer } from "@/api/dto/type";
|
||||
import { encapsulate, OneOrMany } from "@/helpers";
|
||||
import { prepare } from "@/routing";
|
||||
|
||||
const INTERNSHIP_TYPE_INDEX_ENDPOINT = '/internshipTypes'
|
||||
const INTERNSHIP_TYPE_ENDPOINT = INTERNSHIP_TYPE_INDEX_ENDPOINT + "/:id";
|
||||
|
||||
export async function all(): Promise<InternshipType[]> {
|
||||
const response = await axios.get<InternshipTypeDTO[]>(INTERNSHIP_TYPE_INDEX_ENDPOINT);
|
||||
return response.data.map(dto => internshipTypeDtoTransformer.transform(dto))
|
||||
}
|
||||
|
||||
export async function remove(type: OneOrMany<InternshipType>): Promise<void> {
|
||||
await Promise.all(encapsulate(type).map(
|
||||
type => axios.delete(prepare(INTERNSHIP_TYPE_ENDPOINT, { id: type.id as string }))
|
||||
));
|
||||
}
|
||||
|
||||
export async function save(type: InternshipType): Promise<InternshipType> {
|
||||
await axios.put<InternshipType>(
|
||||
INTERNSHIP_TYPE_INDEX_ENDPOINT,
|
||||
internshipTypeDtoTransformer.reverseTransform(type)
|
||||
);
|
||||
|
||||
return type;
|
||||
}
|
15
src/management/common/BulkActions.tsx
Normal file
15
src/management/common/BulkActions.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Actions, ActionsProps } from "@/components";
|
||||
import React from "react";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type BulkActionsProps = ActionsProps;
|
||||
|
||||
export const BulkActions = ({ children, ...props }: BulkActionsProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
return <Actions { ...props }>
|
||||
<Typography variant="subtitle2">{ t("actions.bulk") }: </Typography>
|
||||
{ children }
|
||||
</Actions>;
|
||||
};
|
56
src/management/common/DeleteResourceAction.tsx
Normal file
56
src/management/common/DeleteResourceAction.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { OneOrMany } from "@/helpers";
|
||||
import useTheme from "@material-ui/core/styles/useTheme";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Confirm } from "@/components/confirm";
|
||||
import { Button, IconButton, Tooltip } from "@material-ui/core";
|
||||
import { Delete } from "mdi-material-ui";
|
||||
import { createBoundComponent } from "@/management/common/helpers";
|
||||
|
||||
export type DeleteResourceActionProps<T> = {
|
||||
onDelete: (resource: OneOrMany<T>) => void;
|
||||
resource: OneOrMany<T>;
|
||||
label: (resource: T) => string;
|
||||
children?: (action: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function DeleteResourceAction<T>({ onDelete, resource, children, label }: DeleteResourceActionProps<T>) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
const confirmation = <>
|
||||
{ !Array.isArray(resource)
|
||||
? <Trans i18nKey="confirm.delete">
|
||||
Czy na pewno chcesz usunąć <strong>{ label(resource) }</strong>?
|
||||
</Trans>
|
||||
: <>
|
||||
{ t("confirm.bulk-delete") }
|
||||
<ul>
|
||||
{ resource.map(current => <li key={ label(current) }>{ label(current) }</li>) }
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
</>;
|
||||
|
||||
return <Confirm
|
||||
onConfirm={ () => onDelete(resource) }
|
||||
content={ confirmation }
|
||||
confirm={ props =>
|
||||
<Button variant="contained" startIcon={ <Delete /> }
|
||||
style={{
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
color: theme.palette.error.contrastText,
|
||||
}}
|
||||
{ ...props }>
|
||||
{ t("actions.delete") }
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ action => children ? children(action) : <Tooltip title={ t("actions.delete") as string }><IconButton onClick={ action }><Delete /></IconButton></Tooltip> }
|
||||
</Confirm>;
|
||||
}
|
||||
|
||||
export function createDeleteAction<T>(props: Pick<DeleteResourceActionProps<T>, 'label' | 'onDelete'>) {
|
||||
return createBoundComponent(DeleteResourceAction, props);
|
||||
}
|
||||
|
28
src/management/common/LabelWithIcon.tsx
Normal file
28
src/management/common/LabelWithIcon.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => createStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
},
|
||||
icon: {
|
||||
marginRight: theme.spacing(1),
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
}))
|
||||
|
||||
export type LabelWithIconProps = {
|
||||
icon: React.ReactNode,
|
||||
children: React.ReactChildren,
|
||||
}
|
||||
|
||||
export function LabelWithIcon({ icon, children }: LabelWithIconProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={ classes.root }>
|
||||
<div className={ classes.icon }>{ icon }</div>
|
||||
{ children }
|
||||
</div>
|
||||
}
|
11
src/management/common/MaterialTableTitle.tsx
Normal file
11
src/management/common/MaterialTableTitle.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import { AsyncResult } from "@/hooks";
|
||||
import { CircularProgress } from "@material-ui/core";
|
||||
|
||||
export type MaterialTableTitleProps = { result: AsyncResult<any>, label: React.ReactNode } & React.HTMLProps<HTMLDivElement>;
|
||||
|
||||
export const MaterialTableTitle = ({ label, result, style, ...props }: MaterialTableTitleProps) =>
|
||||
<div style={ { display: "flex", alignItems: "center", ...style } } { ...props }>
|
||||
{ label }
|
||||
{ result.isLoading && <CircularProgress size="1.5rem" style={ { marginLeft: "1rem" } }/> }
|
||||
</div>
|
15
src/management/common/MultilangualCell.tsx
Normal file
15
src/management/common/MultilangualCell.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Multilingual } from "@/data";
|
||||
import React from "react";
|
||||
import { Chip } from "@material-ui/core";
|
||||
|
||||
export type MultilingualCellProps = { value: Multilingual<React.ReactNode> }
|
||||
|
||||
export const MultilingualCell = ({ value }: MultilingualCellProps) => {
|
||||
return <>
|
||||
{ Object.keys(value).map(language => <div>
|
||||
<Chip size="small" label={ language.toUpperCase() } style={ { marginRight: "0.5rem" } }/>
|
||||
{ value[language as keyof Multilingual<any>] }
|
||||
</div>) }
|
||||
</>
|
||||
}
|
||||
|
35
src/management/common/helpers.tsx
Normal file
35
src/management/common/helpers.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Column } from "material-table";
|
||||
import { Actions } from "@/components";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Multilingual } from "@/data";
|
||||
|
||||
export function actionsColumn<T extends Object>(render: (value: T) => React.ReactNode): Column<T> {
|
||||
return {
|
||||
title: <Trans i18nKey="management:actions.label" />,
|
||||
render: value => <Actions style={{ margin: "-1rem" }} spacing={ 0 }>{ render(value) }</Actions>,
|
||||
sorting: false,
|
||||
width: 0,
|
||||
resizable: false,
|
||||
removable: false,
|
||||
searchable: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function createBoundComponent<T, TBoundProps extends keyof T>(Component: React.ComponentType<T>, bound: Pick<T, TBoundProps>) {
|
||||
return (props: Omit<T, TBoundProps>) => <Component { ...bound as any } { ...props } />;
|
||||
}
|
||||
|
||||
export type Comparator<T> = (a: T, b: T) => number;
|
||||
export type MultilingualComparator<T> = Comparator<Multilingual<T>>;
|
||||
|
||||
export function createMultilingualComparator<T>(comparator: Comparator<T>): MultilingualComparator<T> {
|
||||
return (a, b) => comparator(a.pl, b.pl);
|
||||
}
|
||||
|
||||
export const multilingualStringComparator = createMultilingualComparator<string>((a, b) => a && b ? a.localeCompare(b) : 0)
|
||||
export const multilingualNumberComparator = createMultilingualComparator<number>((a, b) => a - b)
|
||||
|
||||
export function fieldComparator<T, K extends keyof T>(field: K, comparator: Comparator<T[K]>): Comparator<T> {
|
||||
return (a, b) => comparator(a[field], b[field])
|
||||
}
|
47
src/management/course/edit.tsx
Normal file
47
src/management/course/edit.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
|
||||
import React from "react";
|
||||
import { Form, Formik } from "formik";
|
||||
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
|
||||
import { Actions } from "@/components";
|
||||
import { Save } from "@material-ui/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cancel } from "mdi-material-ui";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { initialCourseFormValues, CourseForm, CourseFormValues, courseFormValuesTransformer } from "@/management/course/form";
|
||||
import { Course } from "@/data";
|
||||
|
||||
export type EditCourseDialogProps = {
|
||||
onSave?: (page: Course) => void;
|
||||
value?: Course;
|
||||
} & DialogProps;
|
||||
|
||||
export function EditCourseDialog({ onSave, value, ...props }: EditCourseDialogProps) {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(3);
|
||||
|
||||
const handleSubmit = (values: CourseFormValues) => {
|
||||
onSave?.(courseFormValuesTransformer.reverseTransform(values));
|
||||
};
|
||||
|
||||
const initialValues = value
|
||||
? courseFormValuesTransformer.transform(value)
|
||||
: initialCourseFormValues;
|
||||
|
||||
return <Dialog { ...props } maxWidth="lg">
|
||||
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<DialogTitle>{ t(value ? "type.edit.title" : "type.create.title") }</DialogTitle>
|
||||
<DialogContent>
|
||||
<CourseForm />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
|
||||
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Dialog>
|
||||
}
|
||||
|
58
src/management/course/form.tsx
Normal file
58
src/management/course/form.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Course } from "@/data";
|
||||
import { Semester } from "@/data/student";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Field, FieldProps } from "formik";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui";
|
||||
import { Checkbox, Grid } from "@material-ui/core";
|
||||
import { identityTransformer, Transformer } from "@/serialization";
|
||||
|
||||
export type CourseFormValues = Omit<Course, 'id'>;
|
||||
|
||||
export const initialCourseFormValues: CourseFormValues = {
|
||||
name: "",
|
||||
desiredSemesters: []
|
||||
}
|
||||
|
||||
export const courseFormValuesTransformer: Transformer<Course, CourseFormValues> = identityTransformer;
|
||||
|
||||
export const DesiredSemestersField = ({ field, form, meta, ...props }: FieldProps<Semester[]>) => {
|
||||
const { name, value = [] } = field;
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
const toggle = (sid: Semester) => () => {
|
||||
if (!value.includes(sid)) {
|
||||
form.setFieldValue(name, [...value, sid]);
|
||||
} else {
|
||||
form.setFieldValue(name, value.filter((a) => a != sid));
|
||||
}
|
||||
}
|
||||
const isChecked = (sid: Semester) => value.includes(sid);
|
||||
|
||||
const desiredSemesterCheckboxes = [];
|
||||
for (var semesterId = 1; semesterId <= 10; semesterId++) {
|
||||
const sid = semesterId;
|
||||
|
||||
desiredSemesterCheckboxes.push(
|
||||
<Grid item xs={3}>
|
||||
<Checkbox edge="start" onChange={ toggle(sid) } checked={ isChecked(sid) }/>
|
||||
{ t("course.field.desiredSemester", {semesterId: semesterId})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
return <Grid container spacing={3}>
|
||||
{ desiredSemesterCheckboxes }
|
||||
</Grid>
|
||||
}
|
||||
|
||||
export function CourseForm() {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<Field label={ t("page.field.title") } name="name" fullWidth component={ TextFieldFormik }/>
|
||||
<Field name="desiredSemesters" component={ DesiredSemestersField } />
|
||||
</div>
|
||||
}
|
139
src/management/course/list.tsx
Normal file
139
src/management/course/list.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Page } from "@/pages/base";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncState } from "@/hooks";
|
||||
import { Course } from "@/data";
|
||||
import api from "@/management/api";
|
||||
import { Management } from "@/management/main";
|
||||
import { Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import { Async } from "@/components/async";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
|
||||
import { AccountCheck, Delete, Refresh, ShieldCheck } from "mdi-material-ui";
|
||||
import { OneOrMany } from "@/helpers";
|
||||
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Actions } from "@/components";
|
||||
import { MultilingualCell } from "@/management/common/MultilangualCell";
|
||||
import { default as StaticPage } from "@/data/page";
|
||||
import { Add, Edit } from "@material-ui/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { EditStaticPageDialog } from "@/management/page/edit";
|
||||
import { EditCourseDialog } from "@/management/course/edit";
|
||||
|
||||
const title = "course.index.title";
|
||||
|
||||
const label = (course: Course) => course?.name;
|
||||
|
||||
export const CourseManagement = () => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setCoursesPromise] = useAsyncState<Course[]>();
|
||||
const [selected, setSelected] = useState<Course[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateCourseList = () => {
|
||||
setCoursesPromise(api.course.all());
|
||||
}
|
||||
|
||||
const handleCourseDelete = async (type: OneOrMany<Course>) => {
|
||||
await api.course.remove(type);
|
||||
updateCourseList();
|
||||
}
|
||||
|
||||
useEffect(updateCourseList, []);
|
||||
|
||||
const DeleteCourseAction = createDeleteAction({ label, onDelete: handleCourseDelete });
|
||||
|
||||
const CreateCourseAction = () => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleCourseCreation = async (value: Course) => {
|
||||
await api.course.save(value);
|
||||
setOpen(false);
|
||||
updateCourseList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
|
||||
{ open && createPortal(
|
||||
<EditCourseDialog open={ open } onSave={ handleCourseCreation } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const EditCourseAction = ({ resource }: { resource: Course }) => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleCourseCreation = async (value: Course) => {
|
||||
await api.course.save(value);
|
||||
setOpen(false);
|
||||
updateCourseList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("actions.edit") as any }>
|
||||
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
|
||||
</Tooltip>
|
||||
{ open && createPortal(
|
||||
<EditCourseDialog open={ open } onSave={ handleCourseCreation } value={ resource } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const columns: Column<Course>[] = [
|
||||
{
|
||||
field: "id",
|
||||
title: "ID",
|
||||
width: 0,
|
||||
defaultSort: "asc",
|
||||
filtering: false,
|
||||
},
|
||||
{
|
||||
title: t("course.field.name"),
|
||||
render: type => type.name,
|
||||
customSort: (a, b) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: t("course.field.desiredSemesters"),
|
||||
render: type => type.desiredSemesters.slice().sort().join(", "),
|
||||
sorting: false
|
||||
},
|
||||
actionsColumn(type => <>
|
||||
<DeleteCourseAction resource={ type }/>
|
||||
<EditCourseAction resource={ type }/>
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<CreateCourseAction />
|
||||
<Button onClick={ updateCourseList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
<DeleteCourseAction resource={ selected }>
|
||||
{ action => <Button startIcon={ <Delete /> } onClick={ action }>{ t("actions.delete") }</Button> }
|
||||
</DeleteCourseAction>
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
pages => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ pages }
|
||||
onSelectionChange={ pages => setSelected(pages) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
68
src/management/edition/common/StepState.tsx
Normal file
68
src/management/edition/common/StepState.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
import { createStyles, makeStyles } from "@material-ui/core/styles";
|
||||
import { Theme, Tooltip } from "@material-ui/core";
|
||||
import { green, orange, red } from "@material-ui/core/colors";
|
||||
import React, { HTMLProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { stateIcons } from "@/management/edition/proposal/common";
|
||||
import { Remove } from "@material-ui/icons";
|
||||
import { Close } from "mdi-material-ui";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => createStyles({
|
||||
"root": {
|
||||
borderWidth: "2px",
|
||||
borderStyle: "solid",
|
||||
borderRadius: "100%",
|
||||
padding: "0.25rem",
|
||||
display: "inline-block",
|
||||
width: "2.25rem",
|
||||
height: "2.25rem",
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
transform: "scale(0.8)"
|
||||
},
|
||||
"icon": {
|
||||
position: "absolute",
|
||||
bottom: "-12px",
|
||||
right: "-12px",
|
||||
fontSize: "0.25rem",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "100%",
|
||||
transform: "scale(0.75)",
|
||||
padding: "3px",
|
||||
},
|
||||
awaiting: {
|
||||
borderColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
declined: {
|
||||
borderColor: red["600"],
|
||||
color: red["600"],
|
||||
},
|
||||
draft: {},
|
||||
accepted: {
|
||||
borderColor: green["600"],
|
||||
color: green["600"]
|
||||
}
|
||||
}))
|
||||
|
||||
export type StepStateProps = {
|
||||
state: SubmissionStatus | null;
|
||||
label: string;
|
||||
icon: React.ReactChild,
|
||||
} & HTMLProps<HTMLDivElement>;
|
||||
|
||||
export const StepState = ({ label, state, icon, ...props }: StepStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
const classes = useStyles();
|
||||
|
||||
return <Tooltip title={`${label} - ${t(`submission.status.${state || "empty"}`)}`}>
|
||||
<div className={ classNames(classes.root, state && classes[state]) } { ...props }>
|
||||
{ icon }
|
||||
<div className={ classes.icon }>
|
||||
{ state ? stateIcons[state] : <Close /> }
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
185
src/management/edition/form.tsx
Normal file
185
src/management/edition/form.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { Nullable } from "@/helpers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FieldProps, Field, FieldArrayRenderProps, FieldArray, getIn } from "formik";
|
||||
import { identityTransformer, Transformer } from "@/serialization";
|
||||
import {
|
||||
Button, Card, CardContent, CardHeader,
|
||||
Checkbox,
|
||||
Grid, IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from "@material-ui/core";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Moment } from "moment-timezone";
|
||||
import { KeyboardDatePicker as DatePicker } from "@material-ui/pickers";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui";
|
||||
import { Course, Identifiable, InternshipProgramEntry, InternshipType } from "@/data";
|
||||
import { Autocomplete } from "@material-ui/lab";
|
||||
import { useAsync } from "@/hooks";
|
||||
import api from "@/management/api";
|
||||
import { Async } from "@/components/async";
|
||||
import { AccountCheck, ArrowDown, ArrowUp, ShieldCheck, TrashCan } from "mdi-material-ui";
|
||||
import { Actions } from "@/components";
|
||||
import { Add } from "@material-ui/icons";
|
||||
|
||||
export type EditionFormValues = Omit<Nullable<Edition>, "schema">;
|
||||
|
||||
export const initialEditionFormValues: EditionFormValues = {
|
||||
course: null,
|
||||
endDate: null,
|
||||
minimumInternshipHours: 80,
|
||||
proposalDeadline: null,
|
||||
reportingEnd: null,
|
||||
reportingStart: null,
|
||||
startDate: null,
|
||||
types: [],
|
||||
program: [],
|
||||
}
|
||||
|
||||
export const editionFormValuesTransformer: Transformer<Edition, EditionFormValues> = identityTransformer;
|
||||
|
||||
export function toggleValueInArray<T extends Identifiable>(array: T[], value: T, comparator: (a: T, b: T) => boolean = (a, b) => a == b): T[] {
|
||||
return array.findIndex(other => comparator(other, value)) === -1
|
||||
? [ ...array, value ]
|
||||
: array.filter(other => !comparator(other, value));
|
||||
}
|
||||
|
||||
export const ProgramField = ({ remove, swap, push, form, name, ...props }: FieldArrayRenderProps) => {
|
||||
const value = getIn(form.values, name) as InternshipProgramEntry[];
|
||||
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
return <>
|
||||
{ value.map((entry, index) => <Card>
|
||||
<CardHeader
|
||||
subheader={ t('edition.program.entry', { index: index + 1 }) }
|
||||
action={ <>
|
||||
{ index < value.length - 1 && <IconButton onClick={ () => swap(index, index + 1) }><ArrowDown /></IconButton> }
|
||||
{ index > 0 && <IconButton onClick={ () => swap(index, index - 1) }><ArrowUp /></IconButton> }
|
||||
<IconButton onClick={ () => remove(index) }><TrashCan /></IconButton>
|
||||
</> }
|
||||
/>
|
||||
<CardContent>
|
||||
<Field component={ TextFieldFormik }
|
||||
label={ t('edition.program.field.description') }
|
||||
name={`${name}[${index}].description`}
|
||||
fullWidth
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>) }
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => push({ description: "" }) }>{ t("actions.add") }</Button>
|
||||
</Actions>
|
||||
</>
|
||||
}
|
||||
|
||||
export const TypesField = ({ field, form, meta, ...props }: FieldProps<InternshipType[]>) => {
|
||||
const { name, value = [] } = field;
|
||||
|
||||
const types = useAsync(useCallback(() => api.type.all(), []));
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
const toggle = (type: InternshipType) => () => form.setFieldValue(name, toggleValueInArray(value, type, (a, b) => a.id == b.id));
|
||||
const isChecked = (type: InternshipType) => value.findIndex(v => v.id == type.id) !== -1;
|
||||
|
||||
return <Async async={ types }>
|
||||
{ types => <List>{
|
||||
types.map(type => <ListItem dense button onClick={ toggle(type) }>
|
||||
<ListItemIcon>
|
||||
<Checkbox edge="start" onChange={ toggle(type) } checked={ isChecked(type) }/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<div>{ type.label.pl }</div>
|
||||
<Typography variant="caption">{ type.description?.pl }</Typography>
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{ type.requiresDeanApproval && <Tooltip title={ t("type.flag.dean-approval") as string }><AccountCheck/></Tooltip> }
|
||||
{ type.requiresInsurance && <Tooltip title={ t("type.flag.insurance") as string }><ShieldCheck/></Tooltip> }
|
||||
</div>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>)
|
||||
}</List> }
|
||||
</Async>
|
||||
}
|
||||
|
||||
export const CoursePickerField = ({ field, form, meta, ...props }: FieldProps<Course>) => {
|
||||
const courses = useAsync(useCallback(() => api.course.all(), []));
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
return <Autocomplete
|
||||
options={ courses.isLoading ? [] : courses.value as Course[] }
|
||||
renderInput={ props => <TextField { ...props } label={ t("edition.field.course") } fullWidth/> }
|
||||
getOptionLabel={ course => course.name }
|
||||
value={ field.value }
|
||||
onChange={ (_, value) => form.setFieldValue(field.name, value, false) }
|
||||
onBlur={ field.onBlur }
|
||||
/>
|
||||
}
|
||||
|
||||
export const DatePickerField = ({ field, form, meta, ...props }: FieldProps<Moment>) => {
|
||||
const { value, onChange, onBlur } = field;
|
||||
|
||||
return <DatePicker value={ value }
|
||||
onChange={ onChange }
|
||||
onBlur={ onBlur }
|
||||
{ ...props }
|
||||
format="DD.MM.yyyy"
|
||||
disableToolbar fullWidth
|
||||
variant="inline"
|
||||
/>
|
||||
}
|
||||
|
||||
export const EditionForm = () => {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<Typography variant="h5">{ t("edition.fields.basic") }</Typography>
|
||||
<Grid container>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="startDate" component={ DatePickerField } label={ t("edition.field.start") } />
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="endDate" component={ DatePickerField } label={ t("edition.field.end") } />
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="course" component={ CoursePickerField } label={ t("edition.field.course") } />
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="minimumInternshipHours" component={ TextFieldFormik } label={ t("edition.field.minimumInternshipHours") } />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="h5">{ t("edition.fields.deadlines") }</Typography>
|
||||
<Grid container>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="proposalDeadline" component={ DatePickerField } label={ t("edition.field.proposalDeadline") } />
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="reportingStart" component={ DatePickerField } label={ t("edition.field.reportingStart") } />
|
||||
</Grid>
|
||||
<Grid item xs={ 12 } md={ 6 }>
|
||||
<Field name="reportingEnd" component={ DatePickerField } label={ t("edition.field.reportingEnd") } />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Typography variant="h5">{ t("edition.fields.program") }</Typography>
|
||||
<FieldArray name="program" component={ ProgramField as any } />
|
||||
<Typography variant="h5">{ t("edition.fields.types") }</Typography>
|
||||
<Paper elevation={ 2 }>
|
||||
<Field name="types" component={ TypesField } />
|
||||
</Paper>
|
||||
</div>
|
||||
}
|
43
src/management/edition/internship/grade.tsx
Normal file
43
src/management/edition/internship/grade.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import React, { useState } from "react";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, FormControl, InputLabel, MenuItem, Select } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type GradeDialogProps = {
|
||||
internship: InternshipSubmission;
|
||||
onSubmit: (grade: number) => void;
|
||||
} & Omit<DialogProps, "onSubmit">;
|
||||
|
||||
export const GradeDialog = ({ internship, onSubmit, ...props }: GradeDialogProps) => {
|
||||
const [grade, setGrade] = useState<number | null>(internship.grade || null);
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
|
||||
setGrade(event.target.value as number);
|
||||
};
|
||||
|
||||
return <Dialog maxWidth="sm" fullWidth { ...props }>
|
||||
<DialogTitle>{ t("internship.grade") }</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="demo-simple-select-label">{ t("internship.grade") }</InputLabel>
|
||||
<Select
|
||||
labelId="demo-simple-select-label"
|
||||
id="demo-simple-select"
|
||||
value={ grade }
|
||||
onChange={ handleChange }
|
||||
>
|
||||
<MenuItem value={ 2.0 }>2 - Niedostateczny</MenuItem>
|
||||
<MenuItem value={ 3.0 }>3 - Dostateczny</MenuItem>
|
||||
<MenuItem value={ 3.5 }>3.5 - Dostateczny plus</MenuItem>
|
||||
<MenuItem value={ 4.0 }>4 - Dobry</MenuItem>
|
||||
<MenuItem value={ 4.5 }>4.5 - Dobry plus</MenuItem>
|
||||
<MenuItem value={ 5.0 }>5 - Bardzo Dobry</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" color="primary" onClick={ () => onSubmit(grade as number) } disabled={ grade === null }>{ t("save") }</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
}
|
281
src/management/edition/internship/list.tsx
Normal file
281
src/management/edition/internship/list.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncState } from "@/hooks";
|
||||
import { useSpacing } from "@/styles";
|
||||
import api from "@/management/api";
|
||||
import { Box, Button, Container, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { actionsColumn } from "@/management/common/helpers";
|
||||
import {
|
||||
Account,
|
||||
BriefcaseAccount,
|
||||
BriefcaseAccountOutline, CertificateOutline,
|
||||
FileChartOutline,
|
||||
FileFind,
|
||||
FormatPageBreak,
|
||||
Refresh,
|
||||
Star,
|
||||
StickerCheckOutline
|
||||
} from "mdi-material-ui";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Actions } from "@/components";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { Async } from "@/components/async";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { EditionContext, EditionManagement, EditionManagementProps } from "@/management/edition/manage";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FileInfo } from "@/components/fileinfo";
|
||||
import { StepState } from "@/management/edition/common/StepState";
|
||||
import { fullname, Internship, isStudentDataComplete, Student } from "@/data";
|
||||
import { GradeDialog } from "@/management/edition/internship/grade";
|
||||
import { InternshipDetailsDialog } from "@/management/edition/proposal/details";
|
||||
import { AcceptanceActions } from "@/components/acceptance-action";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
import { StudentPreview } from "@/pages/user/profile";
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
import { ReportPreview } from "@/pages/steps/report";
|
||||
import { Report, ReportSchema } from "@/data/report";
|
||||
|
||||
const title = "edition.internships.title";
|
||||
|
||||
export const canGrade = (internship: InternshipSubmission) => !!(internship);
|
||||
|
||||
const ProposalAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDiscard = (comment: string) => {
|
||||
setOpen(false);
|
||||
api.internship.discard(internship as Internship, comment);
|
||||
}
|
||||
|
||||
const handleAccept = (comment?: string) => {
|
||||
setOpen(false);
|
||||
api.internship.accept(internship as Internship, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
{ internship.state
|
||||
? <div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
|
||||
: children }
|
||||
{ createPortal(
|
||||
<InternshipDetailsDialog onDiscard={ handleDiscard } onAccept={ handleAccept } internship={ internship } open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const IPPAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDiscard = (comment: string) => {
|
||||
setOpen(false);
|
||||
api.document.discard(internship.ipp as InternshipDocument, comment);
|
||||
}
|
||||
|
||||
const handleAccept = (comment?: string) => {
|
||||
setOpen(false);
|
||||
api.document.accept(internship.ipp as InternshipDocument, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
{ internship.ipp
|
||||
? <div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
|
||||
: children }
|
||||
{ createPortal(
|
||||
<Dialog maxWidth="md" fullWidth open={ open } onClose={ () => setOpen(false) }>
|
||||
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
|
||||
<DialogContent>
|
||||
{ internship.ipp && <FileInfo document={ internship.ipp } /> }
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="internship"/>
|
||||
</DialogActions>
|
||||
</Dialog>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const ReportAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const edition = useContext(EditionContext);
|
||||
|
||||
const handleDiscard = (comment: string) => {
|
||||
setOpen(false);
|
||||
api.document.discard(internship.evaluation as InternshipDocument, comment);
|
||||
}
|
||||
|
||||
const handleAccept = (comment?: string) => {
|
||||
setOpen(false);
|
||||
api.document.accept(internship.evaluation as InternshipDocument, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
{ internship.report
|
||||
? <div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
|
||||
: children }
|
||||
{ createPortal(
|
||||
<Dialog maxWidth="md" fullWidth open={ open } onClose={ () => setOpen(false) }>
|
||||
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
|
||||
<DialogContent>
|
||||
{ internship.evaluation && <FileInfo document={ internship.evaluation } /> }
|
||||
<ReportPreview schema={ edition?.schema as ReportSchema } report={ internship.report as Report } />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AcceptanceActions onAccept={ handleAccept } onDiscard={ handleDiscard } label="internship"/>
|
||||
</DialogActions>
|
||||
</Dialog>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const StudentAction = ({ internship, children }: { internship: InternshipSubmission, children: React.ReactChild }) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return <>
|
||||
<div onClick={ () => setOpen(true) } style={{ display: "inline-block", cursor: "pointer" }}>{ children }</div>
|
||||
{ createPortal(
|
||||
<Dialog maxWidth="md" fullWidth open={ open } onClose={ () => setOpen(false) }>
|
||||
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
|
||||
<DialogContent>
|
||||
{ internship.intern && <StudentPreview student={ internship.intern } /> }
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
export const InternshipState = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const studentDataState = internship.intern && isStudentDataComplete(internship.intern) ? "accepted" : null;
|
||||
const proposalState = internship.state;
|
||||
const ippState = internship.ipp?.state || null;
|
||||
const reportState = internship.evaluation?.state || null;
|
||||
const gradeState = internship.grade ? "accepted" : null;
|
||||
const approvalState = internship.approvals.reduce<SubmissionStatus | null>((status, document) => {
|
||||
switch (status) {
|
||||
case "awaiting":
|
||||
return status;
|
||||
case "declined":
|
||||
return document.state === "awaiting" ? document.state : status;
|
||||
case "draft":
|
||||
return ["awaiting", "declined"].includes(document.state) ? document.state : status;
|
||||
default:
|
||||
return document.state;
|
||||
}
|
||||
}, null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const spacing = useSpacing(0.25);
|
||||
|
||||
return <div className={ spacing.horizontal } style={{ display: "flex" }}>
|
||||
<StudentAction internship={ internship }>
|
||||
<StepState state={ studentDataState } label={ t("steps.personal-data.header") } icon={ <Account /> } />
|
||||
</StudentAction>
|
||||
<ProposalAction internship={ internship }>
|
||||
<StepState state={ proposalState } label={ t("steps.internship-proposal.header") } icon={ <BriefcaseAccount /> } />
|
||||
</ProposalAction>
|
||||
<IPPAction internship={ internship }>
|
||||
<StepState state={ ippState } label={ t("steps.plan.header") } icon={ <FormatPageBreak /> } />
|
||||
</IPPAction>
|
||||
<ReportAction internship={ internship }>
|
||||
<StepState state={ reportState } label={ t("steps.report.header") } icon={ <FileChartOutline /> } />
|
||||
</ReportAction>
|
||||
<StepState state={ gradeState } label={ t("steps.grade.header") } icon={ <Star /> } />
|
||||
<StepState state={ approvalState } label={ t("steps.approvals.header") } icon={ <CertificateOutline/> }
|
||||
style={ approvalState ? {} : { opacity: 0.2 } }
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export const InternshipManagement = ({ edition }: EditionManagementProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setInternshipsPromise] = useAsyncState<InternshipSubmission[]>();
|
||||
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateInternshipList = () => {
|
||||
setInternshipsPromise(api.internship.all(edition));
|
||||
}
|
||||
|
||||
useEffect(updateInternshipList, []);
|
||||
|
||||
const GradeAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleGradeSubmission = async (grade: number) => {
|
||||
await api.internship.grade(internship as Internship, grade);
|
||||
setOpen(false);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("internship.grade") as string }>
|
||||
<IconButton onClick={ () => setOpen(true) }><StickerCheckOutline/></IconButton>
|
||||
</Tooltip>
|
||||
{ createPortal(
|
||||
<GradeDialog onSubmit={ handleGradeSubmission } internship={ internship } open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const columns: Column<InternshipSubmission>[] = [
|
||||
{
|
||||
title: t("internship.column.student"),
|
||||
render: internship => <>{internship.intern?.name} {internship.intern?.surname}</>,
|
||||
},
|
||||
{
|
||||
title: t("internship.column.album"),
|
||||
field: "intern.albumNumber",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.type"),
|
||||
field: "type.label.pl",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.status"),
|
||||
render: summary => <InternshipState internship={ summary } />
|
||||
},
|
||||
{
|
||||
title: t("internship.column.grade"),
|
||||
field: "grade",
|
||||
width: 0,
|
||||
},
|
||||
actionsColumn(internship => <>
|
||||
{ canGrade(internship) && <GradeAction internship={ internship } /> }
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<Button onClick={ updateInternshipList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
internships => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ internships }
|
||||
onSelectionChange={ internships => setSelected(internships) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
143
src/management/edition/ipp/list.tsx
Normal file
143
src/management/edition/ipp/list.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsync, useAsyncState } from "@/hooks";
|
||||
import { useSpacing } from "@/styles";
|
||||
import api from "@/management/api";
|
||||
import { Box, Button, Container, IconButton, Typography } from "@material-ui/core";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { actionsColumn } from "@/management/common/helpers";
|
||||
import { FileDownloadOutline, FileFind, Refresh, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Actions } from "@/components";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { Async } from "@/components/async";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { EditionManagement, EditionManagementProps } from "@/management/edition/manage";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { route } from "@/routing";
|
||||
import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action";
|
||||
import { ProposalPreview } from "@/components/proposalPreview";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { StateLabel } from "@/management/edition/proposal/common";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Internship } from "@/data";
|
||||
import { FileInfo } from "@/components/fileinfo";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import { InternshipDocument } from "@/api/dto/internship-registration";
|
||||
|
||||
const title = "edition.ipp.title";
|
||||
|
||||
export const canEdit = (ipp: InternshipDocument | null) => !!(ipp && ipp.state != "draft");
|
||||
export const canDownload = (ipp: InternshipDocument | null) => !!(ipp && ipp.id);
|
||||
export const canAccept = (ipp: InternshipDocument | null) => !!(ipp && ["declined", "awaiting"].includes(ipp.state));
|
||||
export const canDiscard = (ipp: InternshipDocument | null) => !!(ipp && ["accepted", "awaiting"].includes(ipp.state));
|
||||
|
||||
export const PlanManagement = ({ edition }: EditionManagementProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setInternshipsPromise] = useAsyncState<InternshipSubmission[]>();
|
||||
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateInternshipList = () => {
|
||||
setInternshipsPromise(api.internship.all(edition));
|
||||
}
|
||||
|
||||
useEffect(updateInternshipList, []);
|
||||
|
||||
const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSubmissionAccept = async (comment?: string) => {
|
||||
setOpen(false);
|
||||
await api.document.accept(internship.ipp as InternshipDocument, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<IconButton onClick={ () => setOpen(true) }><StickerCheckOutline /></IconButton>
|
||||
{ createPortal(
|
||||
<AcceptSubmissionDialog onAccept={ handleSubmissionAccept } label="plan" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const DiscardAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSubmissionDiscard = async (comment: string) => {
|
||||
setOpen(false);
|
||||
await api.document.discard(internship.ipp as InternshipDocument, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<IconButton onClick={ () => setOpen(true) }><StickerRemoveOutline /></IconButton>
|
||||
{ createPortal(
|
||||
<DiscardSubmissionDialog onDiscard={ handleSubmissionDiscard } label="plan" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => {
|
||||
return <Box m={ 3 }>
|
||||
{ summary.ipp ? <FileInfo document={ summary.ipp }/> : <Alert severity="warning" title={ t("ipp.no-submission.title") }>{ t("ipp.no-submission.info") }</Alert> }
|
||||
</Box>
|
||||
}
|
||||
|
||||
const columns: Column<InternshipSubmission>[] = [
|
||||
{
|
||||
title: t("internship.column.student"),
|
||||
render: internship => <>{internship.intern?.name} {internship.intern?.surname}</>,
|
||||
},
|
||||
{
|
||||
title: t("internship.column.album"),
|
||||
field: "intern.albumNumber",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.type"),
|
||||
field: "type.label.pl",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.changed"),
|
||||
render: summary => summary.changed?.format("yyyy-MM-DD")
|
||||
},
|
||||
{
|
||||
title: t("internship.column.status"),
|
||||
render: summary => <StateLabel state={ summary.ipp?.state || null } />
|
||||
},
|
||||
actionsColumn(internship => <>
|
||||
{ canAccept(internship.ipp) && <AcceptAction internship={ internship } /> }
|
||||
{ canDiscard(internship.ipp) && <DiscardAction internship={ internship } /> }
|
||||
{ canDownload(internship.ipp) && <IconButton component={ RouterLink } to={ route("management:edition_internship", { edition: edition.id || "", internship: internship.id || "" }) }><FileDownloadOutline /></IconButton> }
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<Button onClick={ updateInternshipList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
internships => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ internships }
|
||||
onSelectionChange={ internships => setSelected(internships) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
detailPanel={ summary => <InternshipDetails summary={ summary } /> }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
86
src/management/edition/list.tsx
Normal file
86
src/management/edition/list.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Page } from "@/pages/base";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsync } from "@/hooks";
|
||||
import api from "@/management/api";
|
||||
import { Async } from "@/components/async";
|
||||
import { Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { Edition } from "@/data/edition";
|
||||
import { FileFind } from "mdi-material-ui";
|
||||
import { Management } from "../main";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { route } from "@/routing";
|
||||
import { actionsColumn } from "@/management/common/helpers";
|
||||
|
||||
export type EditionDetailsProps = {
|
||||
edition: string;
|
||||
}
|
||||
|
||||
export function EditionDetails({ edition, ...props }: EditionDetailsProps) {
|
||||
const result = useAsync(useCallback(() => api.edition.details(edition), [edition]));
|
||||
|
||||
return <Async async={ result }>{ edition => <pre>{ JSON.stringify(edition, null, 2) }</pre> }</Async>
|
||||
}
|
||||
|
||||
export function EditionsManagement() {
|
||||
const { t } = useTranslation("management");
|
||||
const editions = useAsync(useCallback(api.edition.all, []));
|
||||
|
||||
const ManageEditionAction = ({ edition }: { edition: Edition }) => {
|
||||
const history = useHistory();
|
||||
const handlePagePreview = async () => history.push(route('management:edition_manage', { edition: edition.id || "" }));
|
||||
|
||||
return <Tooltip title={ t("actions.manage") as string }>
|
||||
<IconButton onClick={ handlePagePreview }><FileFind/></IconButton>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
const columns: Column<Edition>[] = [
|
||||
{
|
||||
title: t("edition.field.id"),
|
||||
field: "id",
|
||||
cellStyle: { whiteSpace: "nowrap" }
|
||||
},
|
||||
{
|
||||
title: t("edition.field.start"),
|
||||
render: edition => edition.startDate.format("DD.MM.yyyy"),
|
||||
customSort: (a, b) => b.startDate.unix() - a.startDate.unix(),
|
||||
},
|
||||
{
|
||||
title: t("edition.field.end"),
|
||||
render: edition => edition.endDate.format("DD.MM.yyyy"),
|
||||
customSort: (a, b) => b.endDate.unix() - a.endDate.unix(),
|
||||
},
|
||||
{
|
||||
title: t("edition.field.course"),
|
||||
customSort: (a, b) => a.course.name.localeCompare(b.course.name),
|
||||
render: edition => edition.course.name,
|
||||
},
|
||||
actionsColumn(edition => <>
|
||||
<ManageEditionAction edition={ edition }/>
|
||||
</>),
|
||||
]
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t("edition.index.title") }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ t("edition.index.title") }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg">
|
||||
<Async async={ editions }>
|
||||
{ editions =>
|
||||
<MaterialTable
|
||||
columns={ columns }
|
||||
data={ editions }
|
||||
detailPanel={ edition => <EditionDetails edition={ edition.id as string }/> }
|
||||
title={ t("edition.index.title") }
|
||||
options={ { search: false } }
|
||||
/>
|
||||
}
|
||||
</Async>
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
116
src/management/edition/manage.tsx
Normal file
116
src/management/edition/manage.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouteMatch, Link as RouterLink } from "react-router-dom";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Container, Link, Paper, Typography } from "@material-ui/core";
|
||||
import { Management, ManagementLink } from "@/management/main";
|
||||
import { Edition } from "@/data/edition";
|
||||
import {
|
||||
AccountMultiple,
|
||||
BriefcaseAccount,
|
||||
CertificateOutline,
|
||||
CogOutline,
|
||||
FileAccountOutline,
|
||||
FileChartOutline,
|
||||
FileQuestionOutline,
|
||||
FormatPageBreak
|
||||
} from "mdi-material-ui";
|
||||
import { route, routes, Routes } from "@/routing";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
|
||||
import { useAsync } from "@/hooks";
|
||||
import api from "@/management/api";
|
||||
import { Async } from "@/components/async";
|
||||
import { OneOrMany } from "@/helpers";
|
||||
|
||||
const useSectionStyles = makeStyles((theme: Theme) => createStyles({
|
||||
header: {
|
||||
padding: theme.spacing(2),
|
||||
paddingBottom: 0,
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.grey["800"],
|
||||
},
|
||||
}))
|
||||
|
||||
export function title(edition: Edition) {
|
||||
return `${ edition.course.name } - ${ edition.startDate.year() }`
|
||||
}
|
||||
|
||||
export const EditionContext = React.createContext<Edition | null>(null);
|
||||
|
||||
export const EditionManagement = ({ edition }: EditionManagementProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
|
||||
const spacing = useSpacing(2);
|
||||
const classes = useSectionStyles();
|
||||
|
||||
return <Page>
|
||||
<Page.Header>
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ title(edition) }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ title(edition) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container className={ spacing.vertical }>
|
||||
<Paper elevation={ 2 }>
|
||||
<Typography className={ classes.header }>{ t("edition.manage.internships") }</Typography>
|
||||
<Management.Menu>
|
||||
<ManagementLink icon={ <BriefcaseAccount/> } route={ route("management:edition_internships", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.internships.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FileQuestionOutline/> } route={ route("management:edition_proposals", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.proposals.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:edition_ipp_index", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.ipp.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FileChartOutline/> } route={ route("management:edition_reports", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.reports.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <CertificateOutline/> } route={ route("management:edition_report_form", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.dean-approvals.title") }
|
||||
</ManagementLink>
|
||||
</Management.Menu>
|
||||
</Paper>
|
||||
<Paper elevation={ 2 }>
|
||||
<Typography className={ classes.header }>{ t("edition.manage.management") }</Typography>
|
||||
<Management.Menu>
|
||||
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:edition_schema", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.settings.schema") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <CogOutline/> } route={ route("management:edition_settings", { edition: edition.id || "" }) }>
|
||||
{ t("management:edition.settings.title") }
|
||||
</ManagementLink>
|
||||
</Management.Menu>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
||||
|
||||
EditionManagement.Breadcrumbs = ({ children }: { children: OneOrMany<React.ReactChild> }) => {
|
||||
const edition = useContext<Edition | null>(EditionContext);
|
||||
|
||||
return <Management.Breadcrumbs>
|
||||
{ edition && (children
|
||||
? <Link to={ route("management:edition_manage", { edition: edition.id || "" }) } component={ RouterLink }>{ title(edition) }</Link>
|
||||
: <Typography color="textPrimary">{ title(edition) }</Typography>
|
||||
) }
|
||||
{ children }
|
||||
</Management.Breadcrumbs>
|
||||
}
|
||||
|
||||
export type EditionManagementProps = {
|
||||
edition: Edition;
|
||||
}
|
||||
|
||||
export const EditionRouter = () => {
|
||||
const { params } = useRouteMatch();
|
||||
|
||||
const edition = useAsync<Edition>(useCallback(() => api.edition.details(params.edition), [params.edition]));
|
||||
|
||||
return <Async async={ edition }>{
|
||||
result => <EditionContext.Provider value={ result }>
|
||||
<Routes routes={ routes.filter(route => (route.tags || []).includes("edition")) } edition={ result }/>
|
||||
</EditionContext.Provider>
|
||||
}</Async>
|
||||
}
|
53
src/management/edition/proposal/common.tsx
Normal file
53
src/management/edition/proposal/common.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { SubmissionStatus } from "@/state/reducer/submission";
|
||||
import React from "react";
|
||||
import { ClockOutline, FileQuestion, NotebookCheckOutline, NotebookEditOutline, NotebookRemoveOutline } from "mdi-material-ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip } from "@material-ui/core";
|
||||
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
|
||||
import { green, orange, red } from "@material-ui/core/colors";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { HourglassEmptyRounded } from "@material-ui/icons";
|
||||
|
||||
const useStateLabelStyles = makeStyles((theme: Theme) => createStyles<SubmissionStatus, {}>({
|
||||
awaiting: {
|
||||
borderColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
declined: {
|
||||
borderColor: red["600"],
|
||||
color: red["600"],
|
||||
},
|
||||
draft: {},
|
||||
accepted: {
|
||||
borderColor: green["600"],
|
||||
color: green["600"]
|
||||
}
|
||||
}))
|
||||
|
||||
export type StateLabelProps = {
|
||||
state: SubmissionStatus | null;
|
||||
};
|
||||
|
||||
export const isValidState = (state: string | null) => ["accepted", "draft", "awaiting", "declined"].includes(state as string)
|
||||
|
||||
export const stateIcons: { [sate in SubmissionStatus]: React.ReactElement } = {
|
||||
accepted: <NotebookCheckOutline/>,
|
||||
awaiting: <HourglassEmptyRounded/>,
|
||||
declined: <NotebookRemoveOutline/>,
|
||||
draft: <NotebookEditOutline/>
|
||||
}
|
||||
|
||||
export const StateLabel = ({ state }: StateLabelProps) => {
|
||||
|
||||
const classes = useStateLabelStyles();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return isValidState(state)
|
||||
? <Chip icon={ stateIcons[state as SubmissionStatus] } label={ t(`translation:submission.status.${ state }`) } variant="outlined" className={ classes[state as SubmissionStatus] }/>
|
||||
: <Chip icon={ <FileQuestion /> } label={ t(`translation:submission.status.empty`) } variant="outlined"/>
|
||||
}
|
||||
|
||||
export const canEdit = (internship: InternshipSubmission) => internship.state != "draft";
|
||||
export const canAccept = (internship: InternshipSubmission) => ["declined", "awaiting"].includes(internship.state);
|
||||
export const canDiscard = (internship: InternshipSubmission) => ["accepted", "awaiting"].includes(internship.state);
|
35
src/management/edition/proposal/details.tsx
Normal file
35
src/management/edition/proposal/details.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
|
||||
import { fullname, Internship, Student } from "@/data";
|
||||
import { ProposalPreview } from "@/components/proposalPreview";
|
||||
import { AcceptanceActions } from "@/components/acceptance-action";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import api from "@/management/api";
|
||||
import { useAsync, useAsyncState } from "@/hooks";
|
||||
import { Async } from "@/components/async";
|
||||
|
||||
export type InternshipDetailsDialogProps = {
|
||||
internship: InternshipSubmission;
|
||||
onAccept: (comment?: string) => void;
|
||||
onDiscard: (comment: string) => void;
|
||||
} & DialogProps;
|
||||
|
||||
export const InternshipDetailsDialog = ({ internship, onAccept, onDiscard, ...props }: InternshipDetailsDialogProps) => {
|
||||
const [ details, setPromise ] = useAsyncState();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
setPromise(api.internship.get(internship.id as string));
|
||||
}
|
||||
}, [ props.open, internship.id ])
|
||||
|
||||
return <Dialog maxWidth="lg" fullWidth { ...props }>
|
||||
<DialogTitle>{ fullname(internship.intern as Student) }</DialogTitle>
|
||||
<DialogContent>
|
||||
<Async async={details}>{ internship => <ProposalPreview proposal={ internship as Internship }/> }</Async>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<AcceptanceActions onAccept={ onAccept } onDiscard={ onDiscard } label="internship"/>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
}
|
162
src/management/edition/proposal/list.tsx
Normal file
162
src/management/edition/proposal/list.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsync, useAsyncState } from "@/hooks";
|
||||
import { useSpacing } from "@/styles";
|
||||
import api from "@/management/api";
|
||||
import { Box, Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { actionsColumn } from "@/management/common/helpers";
|
||||
import { FileFind, Refresh, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Actions } from "@/components";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { Async } from "@/components/async";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { EditionManagement, EditionManagementProps } from "@/management/edition/manage";
|
||||
import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action";
|
||||
import { ProposalPreview } from "@/components/proposalPreview";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { canAccept, canDiscard, StateLabel } from "@/management/edition/proposal/common";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Internship } from "@/data";
|
||||
import { InternshipDetailsDialog } from "@/management/edition/proposal/details";
|
||||
|
||||
const title = "edition.internships.title";
|
||||
|
||||
export const ProposalManagement = ({ edition }: EditionManagementProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setInternshipsPromise] = useAsyncState<InternshipSubmission[]>();
|
||||
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateInternshipList = () => {
|
||||
setInternshipsPromise(api.internship.all(edition));
|
||||
}
|
||||
|
||||
useEffect(updateInternshipList, []);
|
||||
|
||||
const handleSubmissionDiscard = async (internship: InternshipSubmission, comment: string) => {
|
||||
await api.internship.discard(internship as Internship, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
const handleSubmissionAccept = async (internship: InternshipSubmission, comment?: string) => {
|
||||
await api.internship.accept(internship as Internship, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleAccept = (comment?: string) => {
|
||||
setOpen(false);
|
||||
handleSubmissionAccept(internship, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("translation:accept") as any }><IconButton onClick={ () => setOpen(true) }><StickerCheckOutline /></IconButton></Tooltip>
|
||||
{ createPortal(
|
||||
<AcceptSubmissionDialog onAccept={ handleAccept } label="internship" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const DiscardAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDiscard = (comment: string) => {
|
||||
setOpen(false);
|
||||
handleSubmissionDiscard(internship, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("translation:discard") as any }><IconButton onClick={ () => setOpen(true) }><StickerRemoveOutline /></IconButton></Tooltip>
|
||||
{ createPortal(
|
||||
<DiscardSubmissionDialog onDiscard={ handleDiscard } label="internship" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const PreviewAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDiscard = (comment: string) => {
|
||||
setOpen(false);
|
||||
handleSubmissionDiscard(internship, comment);
|
||||
}
|
||||
|
||||
const handleAccept = (comment?: string) => {
|
||||
setOpen(false);
|
||||
handleSubmissionAccept(internship, comment);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("translation:preview") as any }><IconButton onClick={ () => setOpen(true) }><FileFind /></IconButton></Tooltip>
|
||||
{ createPortal(
|
||||
<InternshipDetailsDialog onDiscard={ handleDiscard } onAccept={ handleAccept } internship={ internship } open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => {
|
||||
const internship = useAsync(useCallback(() => api.internship.get(summary.id || ""), [ summary.id ]))
|
||||
return <Box m={ 3 }><Async async={ internship }>{ internship => <ProposalPreview proposal={ internship as Internship } /> }</Async> </Box>
|
||||
}
|
||||
|
||||
const columns: Column<InternshipSubmission>[] = [
|
||||
{
|
||||
title: t("internship.column.student"),
|
||||
render: internship => <>{internship.intern?.name} {internship.intern?.surname}</>,
|
||||
},
|
||||
{
|
||||
title: t("internship.column.album"),
|
||||
field: "intern.albumNumber",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.type"),
|
||||
field: "type.label.pl",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.changed"),
|
||||
render: summary => summary.changed?.format("yyyy-MM-DD")
|
||||
},
|
||||
{
|
||||
title: t("internship.column.status"),
|
||||
render: summary => <StateLabel state={ summary.state } />
|
||||
},
|
||||
actionsColumn(internship => <>
|
||||
{ canAccept(internship) && <AcceptAction internship={ internship } /> }
|
||||
{ canDiscard(internship) && <DiscardAction internship={ internship } /> }
|
||||
<PreviewAction internship={ internship } />
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<Button onClick={ updateInternshipList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
internships => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ internships }
|
||||
onSelectionChange={ internships => setSelected(internships) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
detailPanel={ summary => <InternshipDetails summary={ summary } /> }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
60
src/management/edition/report-schema.tsx
Normal file
60
src/management/edition/report-schema.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Button, Card, CardContent, CardHeader, Checkbox, Container, Typography } from "@material-ui/core";
|
||||
import { Async } from "@/components/async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsync } from "@/hooks";
|
||||
import api from "@/management/api";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { EditionManagement, EditionManagementProps } from "./manage";
|
||||
import { ReportFieldDefinition } from "@/data/report";
|
||||
import { FieldPreview } from "@/management/report/fields/list";
|
||||
import { toggleValueInArray } from "@/management/edition/form";
|
||||
import { Actions } from "@/components";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
const title = "edition.settings.schema";
|
||||
|
||||
export function EditionReportSchema({ edition }: EditionManagementProps) {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
const history = useHistory();
|
||||
|
||||
const fields = useAsync<ReportFieldDefinition[]>(useCallback(() => api.field.all(), []))
|
||||
const [selected, setSelected] = useState<ReportFieldDefinition[]>(edition.schema);
|
||||
|
||||
const isSelected = (field: ReportFieldDefinition) => selected.findIndex(f => f.id === field.id) !== -1;
|
||||
const handleCheckboxClick = (field: ReportFieldDefinition) => () => {
|
||||
setSelected(toggleValueInArray(selected, field, (a, b) => a.id === b.id));
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await api.edition.save({ ...edition, schema: selected });
|
||||
history.push("management:edition_manage", { edition: edition.id as string })
|
||||
}
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="md">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="md" className={ spacing.vertical }>
|
||||
<Async async={ fields }>
|
||||
{ fields => <>
|
||||
{ fields.map(field => <div style={{ display: "flex", alignItems: "start" }}>
|
||||
<Checkbox onClick={ handleCheckboxClick(field) } checked={ isSelected(field) }/>
|
||||
<Card style={{ flex: "1 1 auto" }}>
|
||||
<CardHeader subheader={ field.label.pl } />
|
||||
<CardContent><FieldPreview field={ field }/></CardContent>
|
||||
</Card>
|
||||
</div>) }
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" onClick={ handleSave }>{ t("save") }</Button>
|
||||
</Actions>
|
||||
</> }
|
||||
</Async>
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
135
src/management/edition/report/list.tsx
Normal file
135
src/management/edition/report/list.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsync, useAsyncState } from "@/hooks";
|
||||
import { useSpacing } from "@/styles";
|
||||
import api from "@/management/api";
|
||||
import { Box, Button, Container, IconButton, Typography } from "@material-ui/core";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { actionsColumn } from "@/management/common/helpers";
|
||||
import { FileDownloadOutline, FileFind, Refresh, StickerCheckOutline, StickerRemoveOutline } from "mdi-material-ui";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Actions } from "@/components";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { Async } from "@/components/async";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { EditionManagement, EditionManagementProps } from "@/management/edition/manage";
|
||||
import { AcceptSubmissionDialog, DiscardSubmissionDialog } from "@/components/acceptance-action";
|
||||
import { InternshipSubmission } from "@/management/api/internship";
|
||||
import { StateLabel } from "@/management/edition/proposal/common";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Stateful } from "@/data";
|
||||
import { Report } from "@/data/report";
|
||||
|
||||
const title = "edition.reports.title";
|
||||
|
||||
export const canAccept = (subject: Stateful | null) => !!(subject && ["declined", "awaiting"].includes(subject.state));
|
||||
export const canDiscard = (subject: Stateful | null) => !!(subject && ["accepted", "awaiting"].includes(subject.state));
|
||||
|
||||
export const ReportManagement = ({ edition }: EditionManagementProps) => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setInternshipsPromise] = useAsyncState<InternshipSubmission[]>();
|
||||
const [selected, setSelected] = useState<InternshipSubmission[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateInternshipList = () => {
|
||||
setInternshipsPromise(api.internship.all(edition));
|
||||
}
|
||||
|
||||
useEffect(updateInternshipList, []);
|
||||
|
||||
const AcceptAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSubmissionAccept = async (comment?: string) => {
|
||||
setOpen(false);
|
||||
await api.report.accept(internship.report as Report, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<IconButton onClick={ () => setOpen(true) }><StickerCheckOutline /></IconButton>
|
||||
{ createPortal(
|
||||
<AcceptSubmissionDialog onAccept={ handleSubmissionAccept } label="plan" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const DiscardAction = ({ internship }: { internship: InternshipSubmission }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleSubmissionDiscard = async (comment: string) => {
|
||||
setOpen(false);
|
||||
await api.report.discard(internship.report as Report, comment);
|
||||
updateInternshipList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<IconButton onClick={ () => setOpen(true) }><StickerRemoveOutline /></IconButton>
|
||||
{ createPortal(
|
||||
<DiscardSubmissionDialog onDiscard={ handleSubmissionDiscard } label="plan" open={ open } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element,
|
||||
) }
|
||||
</>;
|
||||
}
|
||||
|
||||
const InternshipDetails = ({ summary }: { summary: InternshipSubmission }) => {
|
||||
return <Box m={ 3 }>
|
||||
{ summary.report && JSON.stringify(summary.report) }
|
||||
</Box>
|
||||
}
|
||||
|
||||
const columns: Column<InternshipSubmission>[] = [
|
||||
{
|
||||
title: t("internship.column.student"),
|
||||
render: internship => <>{internship.intern?.name} {internship.intern?.surname}</>,
|
||||
},
|
||||
{
|
||||
title: t("internship.column.album"),
|
||||
field: "intern.albumNumber",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.type"),
|
||||
field: "type.label.pl",
|
||||
},
|
||||
{
|
||||
title: t("internship.column.changed"),
|
||||
render: summary => summary.changed?.format("yyyy-MM-DD")
|
||||
},
|
||||
{
|
||||
title: t("internship.column.status"),
|
||||
render: summary => <StateLabel state={ summary.report?.state || null } />
|
||||
},
|
||||
actionsColumn(internship => <>
|
||||
{ canAccept(internship.report) && <AcceptAction internship={ internship } /> }
|
||||
{ canDiscard(internship.report) && <DiscardAction internship={ internship } /> }
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<Button onClick={ updateInternshipList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
internships => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ internships }
|
||||
onSelectionChange={ internships => setSelected(internships) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
detailPanel={ summary => <InternshipDetails summary={ summary } /> }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
63
src/management/edition/settings.tsx
Normal file
63
src/management/edition/settings.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Container, Divider, Typography, Button } from "@material-ui/core";
|
||||
import { Async } from "@/components/async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { useAsync } from "@/hooks";
|
||||
import { Edition } from "@/data/edition";
|
||||
import api from "@/management/api";
|
||||
import { Form, Formik } from "formik";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { EditionForm, EditionFormValues, editionFormValuesTransformer } from "@/management/edition/form";
|
||||
import { Actions } from "@/components";
|
||||
import { Save } from "@material-ui/icons";
|
||||
import { Cancel } from "mdi-material-ui";
|
||||
import { EditionManagement } from "./manage";
|
||||
|
||||
const title = "edition.settings.title";
|
||||
|
||||
export function EditionSettings() {
|
||||
const { t } = useTranslation("management");
|
||||
const { params } = useRouteMatch();
|
||||
const history = useHistory();
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const edition = useAsync<Edition>(useCallback(() => api.edition.details(params.edition), [params.edition]))
|
||||
|
||||
const handleSubmit = async (values: EditionFormValues) => {
|
||||
const result: Edition = {
|
||||
...edition.value,
|
||||
...editionFormValuesTransformer.reverseTransform(values)
|
||||
};
|
||||
|
||||
await api.edition.save(result);
|
||||
|
||||
history.push("management:edition_manage", { edition: edition.value.id as string })
|
||||
};
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="md">
|
||||
<EditionManagement.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</EditionManagement.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="md">
|
||||
<Async async={ edition }>
|
||||
{ edition =>
|
||||
<Formik initialValues={ edition } onSubmit={ handleSubmit }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<EditionForm />
|
||||
<Divider />
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" type="submit" startIcon={ <Save /> }>{ t("save") }</Button>
|
||||
<Button startIcon={ <Cancel /> }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</Form>
|
||||
</Formik>
|
||||
}
|
||||
</Async>
|
||||
</Container>
|
||||
</Page>;
|
||||
}
|
62
src/management/main.tsx
Normal file
62
src/management/main.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { BreadcrumbsProps, Container, Link, List, ListItem, ListItemIcon, ListItemText, Paper } from "@material-ui/core";
|
||||
import { Page } from "@/pages/base";
|
||||
import React from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { route } from "@/routing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, FileCertificateOutline, FileDocumentMultipleOutline, FormatPageBreak, TableOfContents } from "mdi-material-ui";
|
||||
|
||||
export const ManagementLink = ({ icon, route, children }: ManagementLinkProps) =>
|
||||
<ListItem button component={ RouterLink } to={ route }>
|
||||
<ListItemIcon>{ icon }</ListItemIcon>
|
||||
<ListItemText>{ children }</ListItemText>
|
||||
</ListItem>
|
||||
|
||||
export const Management = {
|
||||
Breadcrumbs: ({ children, ...props }: BreadcrumbsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Page.Breadcrumbs { ...props }>
|
||||
<Link component={ RouterLink } to={ route("management:index") }>{ t("management:title") }</Link>
|
||||
{ children }
|
||||
</Page.Breadcrumbs>;
|
||||
},
|
||||
Menu: List,
|
||||
MenuItem: ManagementLink,
|
||||
}
|
||||
|
||||
type ManagementLinkProps = React.PropsWithChildren<{
|
||||
icon: JSX.Element,
|
||||
route: string,
|
||||
}>;
|
||||
|
||||
export const ManagementIndex = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <Page>
|
||||
<Page.Header>
|
||||
<Page.Title>{ t("management:title") }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container>
|
||||
<Paper elevation={ 2 }>
|
||||
<Management.Menu>
|
||||
<ManagementLink icon={ <TableOfContents /> } route={ route("management:courses") }>
|
||||
{ t("management:course.index.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <CalendarClock /> } route={ route("management:editions") }>
|
||||
{ t("management:edition.index.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FileCertificateOutline /> } route={ route("management:types") }>
|
||||
{ t("management:type.index.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FormatPageBreak/> } route={ route("management:report_fields") }>
|
||||
{ t("management:report-fields.title") }
|
||||
</ManagementLink>
|
||||
<ManagementLink icon={ <FileDocumentMultipleOutline /> } route={ route("management:static_pages") }>
|
||||
{ t("management:page.index.title") }
|
||||
</ManagementLink>
|
||||
</Management.Menu>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
14
src/management/middleware.tsx
Normal file
14
src/management/middleware.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { isLoggedInMiddleware } from "@/middleware";
|
||||
import { useCurrentUser } from "@/hooks";
|
||||
import React from "react";
|
||||
import { Middleware } from "@/routing";
|
||||
|
||||
export const isManagerMiddleware: Middleware<any, any> = Next => isLoggedInMiddleware(() => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
if (user.isManager) {
|
||||
return <Next />;
|
||||
}
|
||||
|
||||
return <div />;
|
||||
})
|
46
src/management/page/edit.tsx
Normal file
46
src/management/page/edit.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
|
||||
import React from "react";
|
||||
import { Form, Formik } from "formik";
|
||||
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
|
||||
import { Actions } from "@/components";
|
||||
import { Save } from "@material-ui/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cancel } from "mdi-material-ui";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { default as StaticPage } from "@/data/page";
|
||||
|
||||
export type EditStaticPageDialogProps = {
|
||||
onSave?: (page: StaticPage) => void;
|
||||
page?: StaticPage;
|
||||
} & DialogProps;
|
||||
|
||||
export function EditStaticPageDialog({ onSave, page, ...props }: EditStaticPageDialogProps) {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(3);
|
||||
|
||||
const handleSubmit = (values: StaticPageFormValues) => {
|
||||
onSave?.(staticPageFormValuesTransformer.reverseTransform(values));
|
||||
};
|
||||
|
||||
const initialValues = page
|
||||
? staticPageFormValuesTransformer.transform(page)
|
||||
: initialStaticPageFormValues;
|
||||
|
||||
return <Dialog { ...props } maxWidth="lg">
|
||||
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<DialogTitle>{ t(page ? "page.edit.title" : "page.create.title") }</DialogTitle>
|
||||
<DialogContent>
|
||||
<StaticPageForm />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
|
||||
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Dialog>
|
||||
}
|
||||
|
40
src/management/page/form.tsx
Normal file
40
src/management/page/form.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { default as StaticPage } from "@/data/page";
|
||||
import { identityTransformer, Transformer } from "@/serialization";
|
||||
import { Field, Form, FormikFormProps } from "formik";
|
||||
import React from "react";
|
||||
import { TextField as TextFieldFormik } from "formik-material-ui";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { CKEditorField } from "@/forms/ckeditor";
|
||||
|
||||
export type StaticPageFormValues = StaticPage;
|
||||
|
||||
export const initialStaticPageFormValues: StaticPageFormValues = {
|
||||
slug: "",
|
||||
title: {
|
||||
en: "",
|
||||
pl: "",
|
||||
},
|
||||
content: {
|
||||
en: "",
|
||||
pl: "",
|
||||
}
|
||||
}
|
||||
|
||||
export const staticPageFormValuesTransformer: Transformer<StaticPage, StaticPageFormValues> = identityTransformer;
|
||||
|
||||
export function StaticPageForm() {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<Field label={ t("page.field.slug") } name="slug" fullWidth component={ TextFieldFormik }/>
|
||||
<Typography variant="subtitle2">{ t("page.field.title") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="title.pl" fullWidth component={ TextFieldFormik }/>
|
||||
<Field label={ t("translation:language.en") } name="title.en" fullWidth component={ TextFieldFormik }/>
|
||||
<Typography variant="subtitle2">{ t("page.field.content") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="content.pl" fullWidth component={ CKEditorField }/>
|
||||
<Field label={ t("translation:language.en") } name="content.en" fullWidth component={ CKEditorField }/>
|
||||
</div>
|
||||
}
|
157
src/management/page/list.tsx
Normal file
157
src/management/page/list.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { Page } from "@/pages/base";
|
||||
import { Management } from "@/management/main";
|
||||
import { Box, Button, CircularProgress, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useAsyncState } from "@/hooks";
|
||||
import api from "@/management/api";
|
||||
import { Async } from "@/components/async";
|
||||
import MaterialTable, { Action, Column } from "material-table";
|
||||
import { default as StaticPage } from "@/data/page";
|
||||
import { Delete, FileFind, Pencil, Refresh } from "mdi-material-ui";
|
||||
import { encapsulate, one, OneOrMany } from "@/helpers";
|
||||
import { Actions } from "@/components";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Add, Edit } from "@material-ui/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { EditStaticPageDialog } from "@/management/page/edit";
|
||||
import { Confirm } from "@/components/confirm";
|
||||
import useTheme from "@material-ui/core/styles/useTheme";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
|
||||
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
|
||||
import { MultilingualCell } from "@/management/common/MultilangualCell";
|
||||
|
||||
const label = (page: StaticPage) => page.title.pl;
|
||||
|
||||
export const StaticPageManagement = () => {
|
||||
const { t } = useTranslation("management");
|
||||
const [ result, setPagesPromise ] = useAsyncState<StaticPage[]>();
|
||||
const [ selected, setSelected ] = useState<StaticPage[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updatePageList = () => {
|
||||
setPagesPromise(api.page.all());
|
||||
}
|
||||
|
||||
useEffect(updatePageList, []);
|
||||
|
||||
const EditStaticPageAction = ({ page }: { page: StaticPage }) => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handlePageCreation = async (page: StaticPage) => {
|
||||
await api.page.save(page);
|
||||
setOpen(false);
|
||||
updatePageList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("actions.edit") as any }>
|
||||
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
|
||||
</Tooltip>
|
||||
{ open && createPortal(
|
||||
<EditStaticPageDialog open={ open } onSave={ handlePageCreation } page={ page } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const CreateStaticPageAction = () => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handlePageCreation = async (page: StaticPage) => {
|
||||
await api.page.save(page);
|
||||
setOpen(false);
|
||||
updatePageList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
|
||||
{ createPortal(
|
||||
<EditStaticPageDialog open={ open } onSave={ handlePageCreation } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const handlePageDeletion = async (page: OneOrMany<StaticPage>) => {
|
||||
await api.page.remove(page);
|
||||
updatePageList();
|
||||
}
|
||||
|
||||
const DeleteStaticPageAction = createDeleteAction<StaticPage>({ label, onDelete: handlePageDeletion })
|
||||
|
||||
const PreviewStaticPageAction = ({ page }: { page: StaticPage }) => {
|
||||
const history = useHistory();
|
||||
const handlePagePreview = async () => history.push(`/${page.slug}`);
|
||||
|
||||
return <Tooltip title={ t("actions.preview") as string }>
|
||||
<IconButton onClick={ handlePagePreview }><FileFind /></IconButton>
|
||||
</Tooltip>;
|
||||
}
|
||||
|
||||
const columns: Column<StaticPage>[] = [
|
||||
{
|
||||
render: page => <MultilingualCell value={ page.title }/>,
|
||||
title: t("page.field.title"),
|
||||
customSort: fieldComparator("title", multilingualStringComparator),
|
||||
},
|
||||
{
|
||||
field: "slug",
|
||||
title: t("page.field.slug"),
|
||||
},
|
||||
actionsColumn(page => <>
|
||||
<EditStaticPageAction page={ page } />
|
||||
<DeleteStaticPageAction resource={ page } />
|
||||
<PreviewStaticPageAction page={ page } />
|
||||
</>)
|
||||
];
|
||||
|
||||
const PagePreview = ({ page }: { page: StaticPage }) =>
|
||||
<Box className={ spacing.vertical } p={ 3 }>
|
||||
<div>
|
||||
<Typography variant="subtitle2">Polski</Typography>
|
||||
<Typography variant="h2">{ page.title.pl }</Typography>
|
||||
<div dangerouslySetInnerHTML={{ __html: page.content.pl }} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="subtitle2">English</Typography>
|
||||
<Typography variant="h2">{ page.title.en }</Typography>
|
||||
<div dangerouslySetInnerHTML={{ __html: page.content.en }} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t("page.index.title") }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ t("page.index.title") }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<CreateStaticPageAction />
|
||||
<Button onClick={ updatePageList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
<DeleteStaticPageAction resource={ selected }>
|
||||
{ action => <Button startIcon={ <Delete /> } onClick={ action }>{ t("actions.delete") }</Button> }
|
||||
</DeleteStaticPageAction>
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
pages => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t("page.index.title") }/> }
|
||||
columns={ columns }
|
||||
data={ pages }
|
||||
detailPanel={ page => <PagePreview page={ page } /> }
|
||||
onSelectionChange={ pages => setSelected(pages) }
|
||||
options={{ selection: true }}
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
||||
|
||||
export default StaticPageManagement;
|
45
src/management/report/fields/edit.tsx
Normal file
45
src/management/report/fields/edit.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
|
||||
import { ReportFieldDefinition } from "@/data/report";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Form, Formik } from "formik";
|
||||
import { Actions } from "@/components";
|
||||
import { Save } from "@material-ui/icons";
|
||||
import { Cancel } from "mdi-material-ui";
|
||||
import { FieldDefinitionForm, FieldDefinitionFormValues, fieldFormValuesTransformer, initialFieldFormValues } from "@/management/report/fields/form";
|
||||
|
||||
export type EditFieldDialogProps = {
|
||||
onSave?: (field: ReportFieldDefinition) => void;
|
||||
field?: ReportFieldDefinition;
|
||||
} & DialogProps;
|
||||
|
||||
export function EditFieldDefinitionDialog({ onSave, field, ...props }: EditFieldDialogProps) {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(3);
|
||||
|
||||
const handleSubmit = (values: FieldDefinitionFormValues) => {
|
||||
onSave?.(fieldFormValuesTransformer.reverseTransform(values));
|
||||
};
|
||||
|
||||
const initialValues = field
|
||||
? fieldFormValuesTransformer.transform(field)
|
||||
: initialFieldFormValues;
|
||||
|
||||
return <Dialog { ...props } maxWidth="md">
|
||||
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<DialogTitle>{ t(field ? "report-field.edit.title" : "report-field.create.title") }</DialogTitle>
|
||||
<DialogContent>
|
||||
<FieldDefinitionForm />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
|
||||
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Dialog>
|
||||
}
|
102
src/management/report/fields/form.tsx
Normal file
102
src/management/report/fields/form.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { ReportFieldDefinition, reportFieldTypes } from "@/data/report";
|
||||
import { identityTransformer, Transformer } from "@/serialization";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Field, FieldArray, FieldProps, useFormikContext } from "formik";
|
||||
import { TextField as TextFieldFormik, Select } from "formik-material-ui";
|
||||
import { FormControl, InputLabel, Typography, MenuItem, Card, Box, Button, CardContent, CardHeader, IconButton } from "@material-ui/core";
|
||||
import { CKEditorField } from "@/forms/ckeditor";
|
||||
import { Multilingual } from "@/data";
|
||||
import { Actions } from "@/components";
|
||||
import { Add } from "@material-ui/icons";
|
||||
import { TrashCan } from "mdi-material-ui";
|
||||
import { FieldPreview } from "@/management/report/fields/list";
|
||||
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
|
||||
|
||||
export type FieldDefinitionFormValues = ReportFieldDefinition | { type: string };
|
||||
|
||||
export const initialFieldFormValues: FieldDefinitionFormValues = {
|
||||
type: "short-text",
|
||||
description: {
|
||||
pl: "",
|
||||
en: "",
|
||||
},
|
||||
label: {
|
||||
pl: "",
|
||||
en: "",
|
||||
},
|
||||
choices: [],
|
||||
}
|
||||
|
||||
export const fieldFormValuesTransformer: Transformer<ReportFieldDefinition, FieldDefinitionFormValues> = identityTransformer;
|
||||
export type ChoiceFieldProps = { name: string };
|
||||
|
||||
const ChoiceField = ({ field, form, meta }: FieldProps) => {
|
||||
const { name } = field;
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<Field label={ t("translation:language.pl") } name={`${name}.pl`} fullWidth component={ TextFieldFormik }/>
|
||||
<Field label={ t("translation:language.en") } name={`${name}.en`} fullWidth component={ TextFieldFormik }/>
|
||||
</div>
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => createStyles({
|
||||
preview: {
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: "#e9f0f5",
|
||||
},
|
||||
}))
|
||||
|
||||
export function FieldDefinitionForm() {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
const { values } = useFormikContext<any>();
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<FormControl variant="outlined">
|
||||
<InputLabel htmlFor="report-field-type">{ t("report-field.field.type") }</InputLabel>
|
||||
<Field
|
||||
component={Select}
|
||||
name="type"
|
||||
label={ t("report-field.field.name") }
|
||||
inputProps={{ id: 'report-field-type', }}
|
||||
>
|
||||
{ reportFieldTypes.map(type => <MenuItem value={ type }>{ t(`report-field.type.${type}`) }</MenuItem>)}
|
||||
</Field>
|
||||
</FormControl>
|
||||
<Typography variant="subtitle2">{ t("report-field.field.label") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="label.pl" fullWidth component={ TextFieldFormik }/>
|
||||
<Field label={ t("translation:language.en") } name="label.en" fullWidth component={ TextFieldFormik }/>
|
||||
<Typography variant="subtitle2">{ t("report-field.field.description") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="description.pl" fullWidth component={ CKEditorField }/>
|
||||
<Field label={ t("translation:language.en") } name="description.en" fullWidth component={ CKEditorField }/>
|
||||
|
||||
{ ["radio", "select", "checkbox"].includes(values.type) && <>
|
||||
<Typography variant="subtitle2">{ t("report-field.field.choices") }</Typography>
|
||||
<FieldArray name="choices" render={ helper => <>
|
||||
{ values.choices.map((value: Multilingual<string>, index: number) => <Card>
|
||||
<CardHeader subheader={ t("report-field.field.choice", { index: index + 1 }) } action={ <>
|
||||
<IconButton onClick={ () => helper.remove(index) }>
|
||||
<TrashCan />
|
||||
</IconButton>
|
||||
</> }/>
|
||||
<CardContent>
|
||||
<Field name={`choices[${index}]`} component={ ChoiceField } />
|
||||
</CardContent>
|
||||
</Card>) }
|
||||
<Actions>
|
||||
<Button variant="contained" startIcon={ <Add /> } color="primary" onClick={() => helper.push({ pl: "", en: "" })}>{ t("actions.add") }</Button>
|
||||
</Actions>
|
||||
</> } />
|
||||
</> }
|
||||
|
||||
<div className={ classes.preview }>
|
||||
<Typography variant="subtitle2">{ t("report-field.preview") }</Typography>
|
||||
<FieldPreview field={ fieldFormValuesTransformer.reverseTransform(values) }/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
122
src/management/report/fields/list.tsx
Normal file
122
src/management/report/fields/list.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Page } from "@/pages/base";
|
||||
import { Management } from "@/management/main";
|
||||
import { Box, Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
|
||||
import { MultilingualCell } from "@/management/common/MultilangualCell";
|
||||
import { ReportFieldDefinition } from "@/data/report";
|
||||
import { Formik } from "formik";
|
||||
import { CustomField } from "@/forms/report";
|
||||
import { Add, Edit } from "@material-ui/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
|
||||
import { EditFieldDefinitionDialog } from "@/management/report/fields/edit";
|
||||
import api from "@/management/api";
|
||||
import { useAsync, useAsyncState } from "@/hooks";
|
||||
import { Async } from "@/components/async";
|
||||
import { Actions } from "@/components";
|
||||
import { Refresh } from "mdi-material-ui";
|
||||
import { useSpacing } from "@/styles";
|
||||
|
||||
const title = "report-fields.title";
|
||||
|
||||
export const FieldPreview = ({ field }: { field: ReportFieldDefinition }) => {
|
||||
return <Formik initialValues={{}} onSubmit={() => {}}>
|
||||
<CustomField field={ field }/>
|
||||
</Formik>
|
||||
}
|
||||
|
||||
export const ReportFields = () => {
|
||||
const { t } = useTranslation("management");
|
||||
const [fields, setFieldsPromise] = useAsyncState<ReportFieldDefinition[]>();
|
||||
|
||||
const updateFieldList = () => {
|
||||
setFieldsPromise(api.field.all());
|
||||
}
|
||||
|
||||
useEffect(updateFieldList, []);
|
||||
|
||||
const handleFieldDeletion = () => {}
|
||||
|
||||
const CreateFieldAction = () => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleFieldCreation = async (value: ReportFieldDefinition) => {
|
||||
await api.field.save(value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
|
||||
{ open && createPortal(
|
||||
<EditFieldDefinitionDialog open={ open } onSave={ handleFieldCreation } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const DeleteFieldAction = createDeleteAction<ReportFieldDefinition>({ label: field => field.label.pl, onDelete: handleFieldDeletion })
|
||||
const EditFieldAction = ({ field }: { field: ReportFieldDefinition }) => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleFieldSave = async (field: ReportFieldDefinition) => {
|
||||
await api.field.save(field);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("actions.edit") as any }>
|
||||
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
|
||||
</Tooltip>
|
||||
{ open && createPortal(
|
||||
<EditFieldDefinitionDialog open={ open } onSave={ handleFieldSave } field={ field } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const columns: Column<ReportFieldDefinition>[] = [
|
||||
{
|
||||
title: t("report-field.field.label"),
|
||||
customSort: fieldComparator('label', multilingualStringComparator),
|
||||
cellStyle: { whiteSpace: "nowrap" },
|
||||
render: field => <MultilingualCell value={ field.label }/>,
|
||||
},
|
||||
{
|
||||
title: t("report-field.field.type"),
|
||||
cellStyle: { whiteSpace: "nowrap" },
|
||||
render: field => t(`report-field.type.${field.type}`),
|
||||
},
|
||||
actionsColumn(field => <>
|
||||
<EditFieldAction field={ field }/>
|
||||
<DeleteFieldAction resource={ field }/>
|
||||
</>),
|
||||
]
|
||||
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<CreateFieldAction />
|
||||
<Button onClick={ updateFieldList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
<Async async={ fields }>
|
||||
{ fields => <MaterialTable
|
||||
columns={ columns }
|
||||
data={ fields }
|
||||
title={ t(title) }
|
||||
detailPanel={ field => <Box p={3}><FieldPreview field={ field } /></Box> }
|
||||
/> }
|
||||
</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
42
src/management/routing.tsx
Normal file
42
src/management/routing.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Route } from "@/routing";
|
||||
import { isManagerMiddleware } from "@/management/middleware";
|
||||
import { CourseManagement } from "@/management/course/list";
|
||||
import { EditionsManagement } from "@/management/edition/list";
|
||||
import React from "react";
|
||||
import { ManagementIndex } from "@/management/main";
|
||||
import StaticPageManagement from "@/management/page/list";
|
||||
import { InternshipTypeManagement } from "@/management/type/list";
|
||||
import { EditionRouter, EditionManagement } from "@/management/edition/manage";
|
||||
import { EditionSettings } from "@/management/edition/settings";
|
||||
import { ProposalManagement } from "@/management/edition/proposal/list";
|
||||
import { PlanManagement } from "@/management/edition/ipp/list";
|
||||
import { ReportFields } from "@/management/report/fields/list";
|
||||
import { ReportManagement } from "@/management/edition/report/list";
|
||||
import { InternshipManagement } from "@/management/edition/internship/list";
|
||||
import { EditionReportSchema } from "@/management/edition/report-schema";
|
||||
|
||||
export const managementRoutes: Route[] = ([
|
||||
{ name: "index", path: "/", content: ManagementIndex, exact: true },
|
||||
|
||||
{ name: "courses", path: "/courses", content: CourseManagement },
|
||||
{ name: "edition_router", path: "/editions/:edition", content: EditionRouter },
|
||||
{ name: "edition_settings", path: "/editions/:edition/settings", content: EditionSettings, tags: ["edition"] },
|
||||
{ name: "edition_manage", path: "/editions/:edition", content: EditionManagement, tags: ["edition"], exact: true },
|
||||
{ name: "edition_internships", path: "/editions/:edition/internships", content: InternshipManagement, tags: ["edition"] },
|
||||
{ name: "edition_proposals", path: "/editions/:edition/proposals", content: ProposalManagement, tags: ["edition"] },
|
||||
{ name: "edition_reports", path: "/editions/:edition/reports", content: ReportManagement, tags: ["edition"] },
|
||||
{ name: "edition_schema", path: "/editions/:edition/schema", content: EditionReportSchema, tags: ["edition"] },
|
||||
{ name: "edition_ipp_index", path: "/editions/:edition/ipp", content: PlanManagement, tags: ["edition"] },
|
||||
{ name: "editions", path: "/editions", content: EditionsManagement },
|
||||
|
||||
{ name: "report_fields", path: "/fields", content: ReportFields },
|
||||
{ name: "types", path: "/types", content: InternshipTypeManagement },
|
||||
{ name: "static_pages", path: "/static-pages", content: StaticPageManagement }
|
||||
] as Route[]).map(
|
||||
({ name, path, middlewares = [], ...route }): Route => ({
|
||||
name: `management:${ name }`,
|
||||
path: `/management${ path }`,
|
||||
middlewares: [ isManagerMiddleware, ...middlewares ],
|
||||
...route
|
||||
})
|
||||
);
|
47
src/management/type/edit.tsx
Normal file
47
src/management/type/edit.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@material-ui/core";
|
||||
import React from "react";
|
||||
import { Form, Formik } from "formik";
|
||||
import { initialStaticPageFormValues, StaticPageForm, StaticPageFormValues, staticPageFormValuesTransformer } from "@/management/page/form";
|
||||
import { Actions } from "@/components";
|
||||
import { Save } from "@material-ui/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cancel } from "mdi-material-ui";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { initialInternshipTypeFormValues, InternshipTypeForm, InternshipTypeFormValues, internshipTypeFormValuesTransformer } from "@/management/type/form";
|
||||
import { InternshipType } from "@/data";
|
||||
|
||||
export type EditInternshipTypeDialogProps = {
|
||||
onSave?: (page: InternshipType) => void;
|
||||
value?: InternshipType;
|
||||
} & DialogProps;
|
||||
|
||||
export function EditInternshipTypeDialog({ onSave, value, ...props }: EditInternshipTypeDialogProps) {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(3);
|
||||
|
||||
const handleSubmit = (values: InternshipTypeFormValues) => {
|
||||
onSave?.(internshipTypeFormValuesTransformer.reverseTransform(values));
|
||||
};
|
||||
|
||||
const initialValues = value
|
||||
? internshipTypeFormValuesTransformer.transform(value)
|
||||
: initialInternshipTypeFormValues;
|
||||
|
||||
return <Dialog { ...props } maxWidth="lg">
|
||||
<Formik initialValues={ initialValues } onSubmit={ handleSubmit }>
|
||||
<Form className={ spacing.vertical }>
|
||||
<DialogTitle>{ t(value ? "type.edit.title" : "type.create.title") }</DialogTitle>
|
||||
<DialogContent>
|
||||
<InternshipTypeForm />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Actions>
|
||||
<Button variant="contained" color="primary" startIcon={ <Save /> } type="submit">{ t("save") }</Button>
|
||||
<Button startIcon={ <Cancel /> } onClick={ ev => props.onClose?.(ev, "escapeKeyDown") }>{ t("cancel") }</Button>
|
||||
</Actions>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Dialog>
|
||||
}
|
||||
|
55
src/management/type/form.tsx
Normal file
55
src/management/type/form.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { InternshipType } from "@/data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Field } from "formik";
|
||||
import { TextField as TextFieldFormik, Checkbox as CheckboxFormik } from "formik-material-ui";
|
||||
import { FormControlLabel, FormGroup, Typography } from "@material-ui/core";
|
||||
import { CKEditorField } from "@/forms/ckeditor";
|
||||
import { AccountCheck, ShieldCheck } from "mdi-material-ui";
|
||||
import { identityTransformer, Transformer } from "@/serialization";
|
||||
import { LabelWithIcon } from "@/management/common/LabelWithIcon";
|
||||
|
||||
export type InternshipTypeFormValues = Omit<InternshipType, 'id'>;
|
||||
|
||||
export const initialInternshipTypeFormValues: InternshipTypeFormValues = {
|
||||
label: {
|
||||
pl: "",
|
||||
en: "",
|
||||
},
|
||||
description: {
|
||||
pl: "",
|
||||
en: "",
|
||||
},
|
||||
requiresInsurance: false,
|
||||
requiresDeanApproval: false,
|
||||
}
|
||||
|
||||
export const internshipTypeFormValuesTransformer: Transformer<InternshipType, InternshipTypeFormValues> = identityTransformer;
|
||||
|
||||
export function InternshipTypeForm() {
|
||||
const { t } = useTranslation("management");
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
return <div className={ spacing.vertical }>
|
||||
<Typography variant="subtitle2">{ t("type.field.label") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="label.pl" fullWidth component={ TextFieldFormik }/>
|
||||
<Field label={ t("translation:language.en") } name="label.en" fullWidth component={ TextFieldFormik }/>
|
||||
<Typography variant="subtitle2">{ t("type.field.description") }</Typography>
|
||||
<Field label={ t("translation:language.pl") } name="description.pl" fullWidth component={ TextFieldFormik }/>
|
||||
<Field label={ t("translation:language.en") } name="description.en" fullWidth component={ TextFieldFormik }/>
|
||||
|
||||
<Typography variant="subtitle2">{ t("type.field.flags") }</Typography>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={ <Field name="requiresDeanApproval" component={ CheckboxFormik }/> }
|
||||
label={ <LabelWithIcon icon={ <AccountCheck /> }>{ t("type.flag.dean-approval") }</LabelWithIcon> }
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={ <Field name="requiresInsurance" component={ CheckboxFormik }/> }
|
||||
label={ <LabelWithIcon icon={ <ShieldCheck /> }>{ t("type.flag.insurance") }</LabelWithIcon> }
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
}
|
147
src/management/type/list.tsx
Normal file
147
src/management/type/list.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { Page } from "@/pages/base";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncState } from "@/hooks";
|
||||
import { InternshipType } from "@/data";
|
||||
import api from "@/management/api";
|
||||
import { Management } from "@/management/main";
|
||||
import { Button, Container, IconButton, Tooltip, Typography } from "@material-ui/core";
|
||||
import { Async } from "@/components/async";
|
||||
import MaterialTable, { Column } from "material-table";
|
||||
import { MaterialTableTitle } from "@/management/common/MaterialTableTitle";
|
||||
import { actionsColumn, fieldComparator, multilingualStringComparator } from "@/management/common/helpers";
|
||||
import { AccountCheck, Delete, Refresh, ShieldCheck } from "mdi-material-ui";
|
||||
import { OneOrMany } from "@/helpers";
|
||||
import { createDeleteAction } from "@/management/common/DeleteResourceAction";
|
||||
import { BulkActions } from "@/management/common/BulkActions";
|
||||
import { useSpacing } from "@/styles";
|
||||
import { Actions } from "@/components";
|
||||
import { MultilingualCell } from "@/management/common/MultilangualCell";
|
||||
import { Add, Edit } from "@material-ui/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { EditInternshipTypeDialog } from "@/management/type/edit";
|
||||
|
||||
const title = "type.index.title";
|
||||
|
||||
const label = (type: InternshipType) => type?.label?.pl;
|
||||
|
||||
export const InternshipTypeManagement = () => {
|
||||
const { t } = useTranslation("management");
|
||||
const [result, setTypesPromise] = useAsyncState<InternshipType[]>();
|
||||
const [selected, setSelected] = useState<InternshipType[]>([]);
|
||||
const spacing = useSpacing(2);
|
||||
|
||||
const updateTypeList = () => {
|
||||
setTypesPromise(api.type.all());
|
||||
}
|
||||
|
||||
const handleTypeDelete = async (type: OneOrMany<InternshipType>) => {
|
||||
await api.type.remove(type);
|
||||
updateTypeList();
|
||||
}
|
||||
|
||||
useEffect(updateTypeList, []);
|
||||
|
||||
const DeleteTypeAction = createDeleteAction({ label, onDelete: handleTypeDelete });
|
||||
|
||||
const CreateTypeAction = () => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleTypeCreation = async (value: InternshipType) => {
|
||||
await api.type.save(value);
|
||||
setOpen(false);
|
||||
updateTypeList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button variant="contained" color="primary" startIcon={ <Add /> } onClick={ () => setOpen(true) }>{ t("create") }</Button>
|
||||
{ open && createPortal(
|
||||
<EditInternshipTypeDialog open={ open } onSave={ handleTypeCreation } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const EditTypeAction = ({ resource }: { resource: InternshipType }) => {
|
||||
const [ open, setOpen ] = useState<boolean>(false);
|
||||
|
||||
const handleTypeCreation = async (value: InternshipType) => {
|
||||
await api.type.save(value);
|
||||
setOpen(false);
|
||||
updateTypeList();
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={ t("actions.edit") as any }>
|
||||
<IconButton onClick={ () => setOpen(true) }><Edit /></IconButton>
|
||||
</Tooltip>
|
||||
{ open && createPortal(
|
||||
<EditInternshipTypeDialog open={ open } onSave={ handleTypeCreation } value={ resource } onClose={ () => setOpen(false) }/>,
|
||||
document.getElementById("modals") as Element
|
||||
) }
|
||||
</>
|
||||
}
|
||||
|
||||
const columns: Column<InternshipType>[] = [
|
||||
{
|
||||
field: "id",
|
||||
title: "ID",
|
||||
width: 0,
|
||||
defaultSort: "asc",
|
||||
filtering: false,
|
||||
},
|
||||
{
|
||||
title: t("type.field.label"),
|
||||
render: type => <MultilingualCell value={ type.label }/>,
|
||||
customSort: fieldComparator("label", multilingualStringComparator),
|
||||
},
|
||||
{
|
||||
title: t("type.field.description"),
|
||||
render: type => type.description && <MultilingualCell value={ type.description }/>,
|
||||
sorting: false,
|
||||
},
|
||||
{
|
||||
title: t("type.field.flags"),
|
||||
render: type => <div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{ type.requiresDeanApproval && <Tooltip title={ t("type.flag.dean-approval") as string }><AccountCheck/></Tooltip> }
|
||||
{ type.requiresInsurance && <Tooltip title={ t("type.flag.insurance") as string }><ShieldCheck/></Tooltip> }
|
||||
</div>,
|
||||
width: 0,
|
||||
filtering: true,
|
||||
sorting: false,
|
||||
},
|
||||
actionsColumn(type => <>
|
||||
<DeleteTypeAction resource={ type }/>
|
||||
<EditTypeAction resource={ type }/>
|
||||
</>)
|
||||
];
|
||||
|
||||
return <Page>
|
||||
<Page.Header maxWidth="lg">
|
||||
<Management.Breadcrumbs>
|
||||
<Typography color="textPrimary">{ t(title) }</Typography>
|
||||
</Management.Breadcrumbs>
|
||||
<Page.Title>{ t(title) }</Page.Title>
|
||||
</Page.Header>
|
||||
<Container maxWidth="lg" className={ spacing.vertical }>
|
||||
<Actions>
|
||||
<CreateTypeAction />
|
||||
<Button onClick={ updateTypeList } startIcon={ <Refresh /> }>{ t("refresh") }</Button>
|
||||
</Actions>
|
||||
{ selected.length > 0 && <BulkActions>
|
||||
<DeleteTypeAction resource={ selected }>
|
||||
{ action => <Button startIcon={ <Delete /> } onClick={ action }>{ t("actions.delete") }</Button> }
|
||||
</DeleteTypeAction>
|
||||
</BulkActions> }
|
||||
<Async async={ result } keepValue>{
|
||||
pages => <MaterialTable
|
||||
title={ <MaterialTableTitle result={ result } label={ t(title) }/> }
|
||||
columns={ columns }
|
||||
data={ pages }
|
||||
onSelectionChange={ pages => setSelected(pages) }
|
||||
options={ { selection: true, pageSize: 10 } }
|
||||
/>
|
||||
}</Async>
|
||||
</Container>
|
||||
</Page>
|
||||
}
|
@ -1,15 +1,28 @@
|
||||
import { Middleware, route } from "@/routing";
|
||||
import { useSelector } from "react-redux";
|
||||
import { isReady } from "@/state/reducer";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import React from "react";
|
||||
import { AppState, isReady } from "@/state/reducer";
|
||||
import { Redirect, useRouteMatch } from "react-router-dom";
|
||||
import React, { useEffect } from "react";
|
||||
import { UserState } from "@/state/reducer/user";
|
||||
|
||||
export const isReadyMiddleware: Middleware<any, any> = next => {
|
||||
export const isReadyMiddleware: Middleware<any, any> = Next => isLoggedInMiddleware(() => {
|
||||
const ready = useSelector(isReady);
|
||||
|
||||
if (ready) {
|
||||
return next();
|
||||
return <Next />;
|
||||
}
|
||||
|
||||
return <Redirect to={ route("edition_pick") } />;
|
||||
})
|
||||
|
||||
export const isLoggedInMiddleware: Middleware<any, any> = Next => {
|
||||
const user = useSelector<AppState>(state => state.user) as UserState;
|
||||
|
||||
if (user.loggedIn) {
|
||||
return <Next />;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem('back-path', window.location.pathname);
|
||||
|
||||
return <Redirect to={ route("user_login") } />;
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user