('');
11 | const [isLoading, setLoading] = useState(false);
12 |
13 | const handleExecuteRecaptcha = useCallback(async () => {
14 | const { executeRecaptcha } = googleRecaptchaContextValue;
15 | if (!executeRecaptcha) {
16 | console.warn('Execute recaptcha function not defined');
17 | return;
18 | }
19 | setLoading(true);
20 | try {
21 | const token = await executeRecaptcha();
22 | setToken(token);
23 | } finally {
24 | setLoading(false);
25 | }
26 | }, [googleRecaptchaContextValue]);
27 |
28 | useEffect(() => {
29 | handleExecuteRecaptcha();
30 |
31 | const interval = setInterval(handleExecuteRecaptcha, CAPTCHA_REFRESH_INTERVAL);
32 | return () => {
33 | clearInterval(interval);
34 | };
35 | }, [handleExecuteRecaptcha]);
36 |
37 | return {
38 | token,
39 | isLoading,
40 | refreshCaptchaToken: handleExecuteRecaptcha,
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/public/components/CountyLinks.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles } from '@material-ui/core';
3 | import { COUNTY } from '../../constants';
4 | import { NavLink } from '../components/NavLink';
5 |
6 | const useStyles = makeStyles({
7 | county: {
8 | display: 'flex',
9 | flexWrap: 'wrap',
10 | justifyContent: 'center',
11 | },
12 | countyLink: {
13 | margin: '1em 5px',
14 | minWidth: 95,
15 | wordWrap: 'break-word',
16 | wordBreak: 'break-all',
17 | minHeight: 60,
18 | },
19 | changeLink: {
20 | display: 'block',
21 | textDecoration: 'underline',
22 | fontStyle: 'normal',
23 | marginTop: 10,
24 | },
25 | });
26 |
27 | interface CountyLinksProps {
28 | linkBase: string;
29 | selected?: string;
30 | }
31 |
32 | export function CountyLinks({ linkBase, selected }: CountyLinksProps) {
33 | const classes = useStyles();
34 | return (
35 |
36 | {COUNTY.filter(c => !selected || c.id === selected).map(county => (
37 | Zmeniť : undefined}
43 | className={classes.countyLink}
44 | />
45 | ))}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/client/src/admin/collectionpoints/EntryDetailDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import Dialog from '@material-ui/core/Dialog';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import DialogContent from '@material-ui/core/DialogContent';
6 | import { CollectionPointEntity } from '../../services';
7 | import { PlaceDetail } from '../../public/components/PlaceDetail';
8 | import { makeStyles, useMediaQuery, useTheme } from '@material-ui/core';
9 |
10 | const useStyles = makeStyles({
11 | dialogFooter: {
12 | justifyContent: 'center',
13 | },
14 | });
15 |
16 | export function EntryDetailDialog({
17 | onCancel,
18 | entity,
19 | }: React.PropsWithChildren<{
20 | entity?: CollectionPointEntity;
21 | onCancel: () => void;
22 | }>) {
23 | const classes = useStyles();
24 | const theme = useTheme();
25 | const isMobile = useMediaQuery(theme.breakpoints.down('xs'));
26 |
27 | return (
28 |
34 |
35 | {entity && }
36 |
37 |
38 |
39 | Späť
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/api/database/migrations/2020_10_28_232632_create_collection_points_table.php:
--------------------------------------------------------------------------------
1 | id();
18 | $table->string('region', 4);
19 | $table->string('county', 60);
20 | $table->string('city', 60);
21 | $table->string('address', 150);
22 | $table->integer('teams')->unsigned()->default(1);
23 | $table->integer('external_system_id')->unsigned()->default(0);
24 | $table->string('external_system_link', 250)->nullable();
25 | $table->time('break_start')->nullable();
26 | $table->time('break_stop')->nullable();
27 | $table->string('break_note', 250)->nullable();
28 | $table->string('note', 250)->nullable();
29 | $table->timestamps();
30 |
31 | $table->index(['region', 'county', 'city', 'address']);
32 | });
33 | }
34 |
35 | /**
36 | * Reverse the migrations.
37 | *
38 | * @return void
39 | */
40 | public function down()
41 | {
42 | Schema::dropIfExists('collection_points');
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Som v rade
21 |
22 |
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/client/src/public/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route, Redirect } from 'react-router-dom';
3 | import { Container } from './components/Container';
4 | import { HomePage } from './home/HomePage';
5 | import { CheckWaiting, PlaceDetailPage as CheckWaitingPlaceDetail } from './checkwaiting';
6 | import { SetWaiting, PlaceRegister } from './setwaiting';
7 | import { Favorites } from './favorites';
8 | import { NotFound } from './notfound';
9 |
10 | export function Public() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "somvrade",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@date-io/date-fns": "^1.3.13",
7 | "@material-ui/core": "^4.11.0",
8 | "@material-ui/icons": "^4.9.1",
9 | "@material-ui/lab": "^4.0.0-alpha.56",
10 | "@material-ui/pickers": "^3.2.10",
11 | "@testing-library/jest-dom": "^5.11.5",
12 | "@testing-library/react": "^11.1.0",
13 | "@testing-library/user-event": "^12.1.10",
14 | "@types/classnames": "2.2.6",
15 | "@types/jest": "^26.0.15",
16 | "@types/match-sorter": "5.0.0",
17 | "@types/node": "^12.19.2",
18 | "@types/react": "^16.9.54",
19 | "@types/react-dom": "^16.9.9",
20 | "@types/react-router-dom": "5.1.6",
21 | "@types/react-window": "1.8.2",
22 | "classnames": "2.2.6",
23 | "date-fns": "^2.16.1",
24 | "match-sorter": "5.0.0",
25 | "prettier": "2.1.2",
26 | "react": "^17.0.1",
27 | "react-dom": "^17.0.1",
28 | "react-google-recaptcha-v3": "^1.7.0",
29 | "react-router-dom": "5.2.0",
30 | "react-scripts": "4.0.0",
31 | "react-share": "4.3.1",
32 | "react-window": "1.8.6",
33 | "typescript": "^4.0.5",
34 | "web-vitals": "^0.2.4"
35 | },
36 | "scripts": {
37 | "start": "react-scripts start",
38 | "build": "react-scripts build",
39 | "test": "react-scripts test",
40 | "eject": "react-scripts eject"
41 | },
42 | "eslintConfig": {
43 | "extends": [
44 | "react-app",
45 | "react-app/jest"
46 | ]
47 | },
48 | "browserslist": {
49 | "production": [
50 | ">0.2%",
51 | "not dead",
52 | "not op_mini all"
53 | ],
54 | "development": [
55 | "last 1 chrome version",
56 | "last 1 firefox version",
57 | "last 1 safari version"
58 | ]
59 | },
60 | "proxy": "http://localhost:8000"
61 | }
62 |
--------------------------------------------------------------------------------
/api/routes/web.php:
--------------------------------------------------------------------------------
1 | group(['prefix' => 'api'], function () use ($router) {
17 | $router->get('/version', function () use ($router) {
18 | return response()->json(['name' => 'Som v rade.sk API', 'version' => '1.2.0']);
19 | });
20 | $router->get('collectionpoints', 'CollectionPointsController@showAll');
21 | $router->get('collectionpoints/{id}', 'CollectionPointsController@showOne');
22 | $router->get('collectionpoints/{id}/entries', 'EntryController@showAll');
23 | $router->post('collectionpoints/{id}/entries', 'EntryController@create');
24 | $router->put('entries/{eid}', 'EntryController@update');
25 | $router->delete('entries/{eid}', 'EntryController@delete');
26 | $router->group([
27 | 'middleware' => 'auth',
28 | ], function ($router) {
29 | $router->put('collectionpoints/{id}/break', 'CollectionPointsController@updateBreak');
30 | });
31 |
32 | $router->post('login', 'AuthController@login');
33 | $router->group([
34 | 'middleware' => 'auth',
35 | 'prefix' => 'auth'
36 | ], function ($router) {
37 | $router->post('logout', 'AuthController@logout');
38 | $router->post('refresh', 'AuthController@refresh');
39 | $router->post('me', 'AuthController@me');
40 | $router->get('collectionpoints', 'CollectionPointsController@showMine');
41 | });
42 | });
--------------------------------------------------------------------------------
/api/app/Exceptions/Handler.php:
--------------------------------------------------------------------------------
1 | json([
59 | 'code' => $rendered->getStatusCode(),
60 | 'message' => $exception->getMessage(),
61 | ], $rendered->getStatusCode());
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/client/src/public/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { makeStyles, Typography, Fab } from '@material-ui/core';
4 | import classNames from 'classnames';
5 | import { useSession } from '../../Session';
6 | import NavigationIcon from '@material-ui/icons/Navigation';
7 |
8 | const useStyles = makeStyles(theme => ({
9 | margin: {
10 | marginBottom: '1.5rem',
11 | },
12 | compact: {
13 | // background: theme.palette.action.hover,
14 | padding: 10,
15 |
16 | '& h1': {
17 | fontSize: '2rem',
18 | // fontWeight: 600,
19 | transition: 'all 0.3s',
20 | },
21 | },
22 | headingLink: {
23 | textDecoration: 'none',
24 | },
25 | fab: {
26 | position: 'fixed',
27 | bottom: theme.spacing(2),
28 | right: theme.spacing(10),
29 | },
30 | }));
31 |
32 | export function Header({ compact }: { compact: boolean }) {
33 | const classes = useStyles();
34 | const [session] = useSession();
35 | return (
36 |
37 |
38 | Som v rade
39 |
40 |
41 | {compact
42 | ? 'a chcem pomôcť'
43 | : 'Aktuálne informácie o dĺžke čakania na antigénové testovanie COVID-19.'}
44 |
45 | {session.isRegistered && (
46 |
49 |
50 |
51 | Moje odberné miesto
52 |
53 |
54 | )}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/client/src/public/components/NavLink.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import { makeStyles, createStyles, Theme } from '@material-ui/core';
3 | import { Link as RouterLink } from 'react-router-dom';
4 | import classNames from 'classnames';
5 |
6 | const useStyles = makeStyles((theme: Theme) =>
7 | createStyles({
8 | link: {
9 | display: 'block',
10 | color: theme.palette.text.primary,
11 | fontSize: '1rem',
12 | textDecoration: 'none',
13 | margin: 5,
14 | flex: '0 1 50%',
15 | textAlign: 'center',
16 |
17 | '& span:first-child': {
18 | textAlign: 'center',
19 | border: `1px solid ${theme.palette.primary.main}`,
20 | padding: '20px 7px',
21 | display: 'flex',
22 | borderRadius: 5,
23 | background: theme.palette.action.hover,
24 | '&:hover': {
25 | background: theme.palette.action.selected,
26 | },
27 | minHeight: 60,
28 | alignItems: 'center',
29 | wordWrap: 'break-word',
30 | overflowWrap: 'anywhere',
31 | justifyContent: 'center',
32 | },
33 | '& span:last-child': {
34 | fontSize: '0.8rem',
35 | display: 'block',
36 | },
37 | },
38 | compact: {
39 | fontSize: '0.9rem',
40 | flex: '0',
41 | '& span:first-child': {
42 | padding: '7px',
43 | },
44 | },
45 | }),
46 | );
47 |
48 | interface NavLinkProps {
49 | to: string;
50 | label: string;
51 | description?: ReactNode;
52 | compact?: boolean;
53 | className?: string;
54 | }
55 |
56 | export function NavLink({ to, label, description, compact, className }: NavLinkProps) {
57 | const classes = useStyles();
58 | return (
59 |
60 | {label}
61 | {description}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/client/src/public/home/components/PlaceSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TextField from '@material-ui/core/TextField';
3 | import Autocomplete from '@material-ui/lab/Autocomplete';
4 | import { Typography, Link } from '@material-ui/core';
5 | import { PlaceType } from './PlaceType';
6 | import { PlacesContext } from './PlacesContext';
7 |
8 | export default function PlaceSelector() {
9 | return (
10 |
11 | {({ places, error }) => (
12 | <>
13 | {error != null && (
14 |
15 | {JSON.stringify(error)}
16 |
17 | )}
18 | option.city}
21 | getOptionLabel={getPlaceLabelPlain}
22 | renderOption={getPlaceLabel}
23 | disabled={error != null}
24 | style={{ width: '100%' }}
25 | noOptionsText={
26 |
27 | Odberné miesto nebolo nájdené, môžete{' '}
28 | ho pridať.
29 |
30 | }
31 | renderInput={params => (
32 |
41 | )}
42 | />
43 | >
44 | )}
45 |
46 | );
47 | }
48 |
49 | function getPlaceLabel(place: PlaceType) {
50 | return (
51 |
52 |
53 | {place.county === undefined ? '-' : place.county}, {place.city}
54 |
55 | , {place.district}, {place.place}
56 |
57 | );
58 | }
59 |
60 | function getPlaceLabelPlain(place: PlaceType) {
61 | return `${place.county === undefined ? '-' : place.county}, ${place.city}, ${place.district}, ${
62 | place.place
63 | }`;
64 | }
65 |
--------------------------------------------------------------------------------
/api/app/Models/User.php:
--------------------------------------------------------------------------------
1 | getKey();
49 | }
50 |
51 | /**
52 | * Return a key value array, containing any custom claims to be added to the JWT.
53 | *
54 | * @return array
55 | */
56 | public function getJWTCustomClaims()
57 | {
58 | return [];
59 | }
60 |
61 | public function collectionPoints()
62 | {
63 | return $this->belongsToMany(
64 | CollectionPoints::class,
65 | 'collection_points_users',
66 | 'user_id',
67 | 'collection_point_id');
68 | }
69 |
70 | /**
71 | * @param $id
72 | * @return CollectionPoints|null
73 | */
74 | public function allowedCollectionPoint($id) {
75 | $collectionPoint = $this->collectionPoints()->whereKey($id)->first();
76 | if ($collectionPoint instanceof CollectionPoints) {
77 | return $collectionPoint;
78 | }
79 | return null;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/client/src/public/components/ExternalPartners.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Typography, Link, makeStyles } from '@material-ui/core';
3 | import OpenInNewIcon from '@material-ui/icons/OpenInNew';
4 |
5 | const useStyles = makeStyles(theme => ({
6 | icon: {
7 | fontSize: 60,
8 | color: theme.palette.action.active,
9 | },
10 | iconWrapper: {
11 | textAlign: 'center',
12 | },
13 | }));
14 |
15 | interface OnlineBookingProps {
16 | link?: string|null;
17 | }
18 |
19 | export function OdberneMiesta() {
20 | return (
21 |
22 |
23 |
24 | Toto odberné miesto využíva na informovanie o čakacích dobách webovú stránku{' '}
25 |
26 | odbernemiesta.sk
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export function OnlineBooking(props: OnlineBookingProps) {
34 | return (
35 |
36 |
37 |
38 | Toto odberné miesto využíva externý objednávkový systém {' '}
39 | {props.link && (
40 | <>
41 | na adrese {' '}
42 |
43 | {props.link}
44 |
45 | >
46 | )}.
47 |
48 |
49 | );
50 | }
51 |
52 | export function DefaultExternal() {
53 | return (
54 |
55 |
56 |
57 | Toto odberné miesto využíva vlastný systém.
58 | Pre viac informácii navštívte webovú stránku mesta / obce.
59 |
60 |
61 | );
62 | }
63 |
64 | function ExternalIndicator() {
65 | const classes = useStyles();
66 | return (
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/client/src/admin/home/AdminHomePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | Button,
4 | makeStyles,
5 | Typography,
6 | Paper,
7 | Toolbar,
8 | AppBar,
9 | Tabs,
10 | Tab,
11 | } from '@material-ui/core';
12 | import { useSession } from '../../Session';
13 | import { CollectionPoints } from '../collectionpoints';
14 |
15 | const useStyles = makeStyles({
16 | container: {
17 | margin: '80px 0',
18 | padding: 0,
19 | display: 'flex',
20 | flexDirection: 'column',
21 | },
22 | title: {
23 | flexGrow: 1,
24 | color: '#FFF',
25 | },
26 | toolbar: {
27 | zIndex: 9,
28 | },
29 | });
30 |
31 | interface TabPanelProps {
32 | children?: React.ReactNode;
33 | index: any;
34 | value: any;
35 | }
36 |
37 | export function AdminHomePage() {
38 | const [, sessionActions] = useSession();
39 | const classes = useStyles();
40 |
41 | const [value, setValue] = React.useState(0);
42 |
43 | const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
44 | setValue(newValue);
45 | };
46 |
47 | return (
48 | <>
49 |
50 |
51 |
52 | Administrácia somvrade.sk
53 |
54 | sessionActions.destroySession()} color="inherit">
55 | Odhlásenie
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | >
69 | );
70 | }
71 |
72 | function TabPanel(props: TabPanelProps) {
73 | const { children, value, index, ...other } = props;
74 |
75 | return (
76 |
83 | {value === index && children}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/client/src/public/components/SocialButtons.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { makeStyles } from '@material-ui/core';
3 | import FacebookShareButton from 'react-share/es/FacebookShareButton';
4 | import FacebookIcon from 'react-share/es/FacebookIcon';
5 | import TwitterShareButton from 'react-share/es/TwitterShareButton';
6 | import TwitterIcon from 'react-share/es/TwitterIcon';
7 | import SpeedDial from '@material-ui/lab/SpeedDial';
8 | import SpeedDialAction from '@material-ui/lab/SpeedDialAction';
9 | import ShareIcon from '@material-ui/icons/Share';
10 |
11 | const useStyles = makeStyles(theme => ({
12 | speedDial: {
13 | position: 'fixed',
14 | left: 20,
15 | bottom: 20,
16 | },
17 | }));
18 |
19 | export function SocialButtons() {
20 | const classes = useStyles();
21 |
22 | const [open, setOpen] = useState(false);
23 |
24 | const handleClose = () => {
25 | setOpen(false);
26 | };
27 |
28 | const handleOpen = () => {
29 | setOpen(true);
30 | };
31 |
32 | const fabProps = {
33 | component: React.forwardRef(
34 | (
35 | { children, className }: React.PropsWithChildren<{ className: string }>,
36 | ref: React.Ref,
37 | ) => {
38 | return (
39 |
40 | {children}
41 |
42 | );
43 | },
44 | ),
45 | };
46 |
47 | return (
48 | }
56 | >
57 |
60 |
61 |
62 | }
63 | tooltipPlacement={'right'}
64 | tooltipTitle={'Zdieľaj na Facebooku'}
65 | onClick={handleClose}
66 | FabProps={{
67 | ...(fabProps as any), // workaround to pass custom component
68 | }}
69 | />
70 |
73 |
74 |
75 | }
76 | FabProps={{
77 | ...(fabProps as any), // workaround to pass custom component
78 | }}
79 | tooltipPlacement={'right'}
80 | tooltipTitle={'Zdieľaj na Twitteri'}
81 | onClick={handleClose}
82 | />
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/client/src/public/setwaiting/PlaceRegister.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import PlaceIcon from '@material-ui/icons/Place';
4 | import { Typography, makeStyles } from '@material-ui/core';
5 | import LinearProgress from '@material-ui/core/LinearProgress';
6 | import Alert from '@material-ui/lab/Alert';
7 | import Button from '@material-ui/core/Button';
8 |
9 | import { useCollectionPointsPublic } from '../../services';
10 | import { TextLink } from '../components/TextLink';
11 | import { BackToStartLink } from '../components/BackToStartLink';
12 | import { useSession } from '../../Session';
13 | import { UpdateDeparture } from './UpdateDeparture';
14 | import { RegisterPlace } from './RegisterPlace';
15 |
16 | const useStyles = makeStyles({
17 | placeTitle: {
18 | fontStyle: 'italic',
19 | fontSize: '1.2rem',
20 | lineHeight: '1.2rem',
21 | marginBottom: 20,
22 | },
23 | });
24 |
25 | export function PlaceRegister() {
26 | const classes = useStyles();
27 | const { county, id } = useParams<{ county: string; id: string }>();
28 | const { isLoading, response, error, refresh } = useCollectionPointsPublic(county);
29 | const detail = response?.find(it => String(it.id) === id);
30 | const [session] = useSession();
31 |
32 | return isLoading ? (
33 |
34 | ) : (
35 | <>
36 | {!detail && !error && Odberné miesto nenájdené }
37 | {error && (
38 |
42 | Obnoviť
43 |
44 | }
45 | >
46 | Nastala neznáma chyba
47 |
48 | )}
49 | {detail && (
50 |
51 |
52 | Na odbernom mieste
53 |
54 |
55 | {detail.address}
56 |
57 | {!session.isRegistered && !session.registeredToken?.completed && (
58 |
59 | )}
60 | {session.isRegistered && !session.registeredToken?.completed &&
}
61 | {session.registeredToken?.completed && (
62 |
63 | Vaše údaje boli uložené. Prispeli ste k hladkému priebehu celoplošného testovania a
64 | pomohli ste mnohým ľuďom. Ďakujeme!
65 |
66 | )}
67 |
68 |
73 |
74 |
75 | )}
76 | >
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/client/src/admin/login/Login.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, useState } from 'react';
2 | import { TextField, Button, makeStyles, Typography } from '@material-ui/core';
3 | import Snackbar from '@material-ui/core/Snackbar';
4 | import Alert from '@material-ui/lab/Alert';
5 | import Paper from '@material-ui/core/Paper';
6 | import LinearProgress from '@material-ui/core/LinearProgress';
7 | import { useSession } from '../../Session';
8 | import { login } from '../../services';
9 | import { TextLink } from '../../public/components/TextLink';
10 |
11 | const useStyles = makeStyles({
12 | container: {
13 | marginTop: 50,
14 | padding: 20,
15 | display: 'flex',
16 | flexDirection: 'column',
17 | alignItems: 'center',
18 | },
19 | form: {
20 | display: 'flex',
21 | flexDirection: 'column',
22 | maxWidth: 300,
23 | width: '100%',
24 |
25 | '& > *': {
26 | margin: 10,
27 | },
28 | },
29 | });
30 |
31 | export function LoginPage() {
32 | const [, sessionActions] = useSession();
33 | const [errorMessage, setErrorMessage] = useState('');
34 | const [isLoginLoading, setLoginLoading] = useState(false);
35 | const classes = useStyles();
36 |
37 | async function handleSubmit(evt: FormEvent) {
38 | evt.preventDefault();
39 | const form = evt.target as any;
40 | const username = form.username.value;
41 | const password = form.password.value;
42 |
43 | try {
44 | if (!username || !password) {
45 | throw new Error('email and password required');
46 | }
47 | setLoginLoading(true);
48 | const resp = await login(form.username.value, form.password.value);
49 | sessionActions.initSecureSession({
50 | accessToken: resp.token,
51 | tokenType: resp.token_type,
52 | expiresIn: resp.expires_in,
53 | });
54 | } catch {
55 | setErrorMessage('Prihlásenie neúspešné.');
56 | setLoginLoading(false);
57 | }
58 | }
59 |
60 | function closeMessage() {
61 | setErrorMessage('');
62 | }
63 |
64 | return (
65 |
66 | Prihlásenie
67 |
76 |
82 |
83 | {errorMessage}
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/client/src/public/favorites/Favorites.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { makeStyles, Typography } from '@material-ui/core';
3 | import { useParams, useHistory } from 'react-router-dom';
4 | import Grid from '@material-ui/core/Grid';
5 | import FaceOutlinedIcon from '@material-ui/icons/BookmarkBorder';
6 | import Alert from '@material-ui/lab/Alert';
7 | import { PlaceDetail } from '../components/PlaceDetail';
8 | import { useSession } from '../../Session';
9 | import { BackToStartLink } from '../components/BackToStartLink';
10 | import { MAX_FAVORITES } from '../../constants';
11 | import { SocialButtons } from '../components/SocialButtons';
12 |
13 | const useStyles = makeStyles({
14 | container: {
15 | display: 'flex',
16 | flexWrap: 'wrap',
17 | justifyContent: 'space-between',
18 | },
19 | detail: {
20 | border: '1px solid #dcdcdc',
21 | borderRadius: 4,
22 | padding: 10,
23 | },
24 | title: {
25 | marginBottom: 30,
26 | },
27 | });
28 |
29 | export function Favorites() {
30 | const classes = useStyles();
31 | const params = useParams<{ ids: string }>();
32 | const history = useHistory();
33 | const pairs = (params.ids || '').split(',');
34 | const [session] = useSession();
35 | const nextRender = useRef(false);
36 |
37 | useEffect(() => {
38 | if (nextRender.current) {
39 | if (!(session.favorites || []).length) {
40 | history.push('/watching');
41 | } else {
42 | history.push(
43 | `/watching/${session.favorites!.map(it => it.county + ':' + it.entryId).join(',')}`,
44 | );
45 | }
46 | }
47 |
48 | nextRender.current = true;
49 | // eslint-disable-next-line
50 | }, [session]);
51 |
52 | useEffect(() => {
53 | // protection for max watching places
54 | if (pairs.length > MAX_FAVORITES) {
55 | history.replace(`/watching/${pairs.slice(0, MAX_FAVORITES).join(',')}`);
56 | }
57 | });
58 |
59 | const favorites = pairs
60 | .filter(pair => !!pair)
61 | .map(pair => {
62 | const items = pair.split(':');
63 | return {
64 | county: items[0],
65 | entityId: items[1],
66 | };
67 | });
68 |
69 | return (
70 | <>
71 |
72 | Sledované odberné miesta
73 |
74 | {!favorites.length && (
75 |
76 | Žiadne sledované odberné miesta. Začať sledovať odberné miesto môžete kliknutím na ikonu{' '}
77 | nad tabuľkou odberného miesta.
78 |
79 | )}
80 |
81 | {favorites.map(fav => (
82 |
83 |
90 |
91 | ))}
92 |
93 |
94 | {!!favorites.length && }
95 | >
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/api/app/Http/Controllers/AuthController.php:
--------------------------------------------------------------------------------
1 | validate($request, [
37 | 'name' => 'required|string',
38 | 'email' => 'required|email',
39 | 'password' => 'required',
40 | ]);
41 |
42 | try {
43 |
44 | $user = new User;
45 | $user->name = $request->input('name');
46 | $user->email = $request->input('email');
47 | $plainPassword = $request->input('password');
48 | $user->password = app('hash')->make($plainPassword);
49 |
50 | $user->save();
51 |
52 | //return successful response
53 | return response()->json(['user' => $user, 'message' => 'CREATED'], 201);
54 |
55 | } catch (Exception $e) {
56 | //return error message
57 | return response()->json(['message' => 'User Registration Failed!'], 409);
58 | }
59 |
60 | }
61 |
62 | /**
63 | * Get a JWT via given credentials.
64 | *
65 | * @param Request $request
66 | * @return JsonResponse
67 | * @throws ValidationException
68 | */
69 | public function login(Request $request)
70 | {
71 | //validate incoming request
72 | $this->validate($request, [
73 | 'email' => 'required|string',
74 | 'password' => 'required|string',
75 | ]);
76 |
77 | $credentials = $request->only(['email', 'password']);
78 |
79 | if (! $token = Auth::attempt($credentials)) {
80 | return response()->json(['message' => 'Unauthorized'], 401);
81 | }
82 |
83 | return $this->respondWithToken($token);
84 | }
85 |
86 |
87 | /**
88 | * Get the authenticated User.
89 | *
90 | * @return JsonResponse
91 | */
92 | public function me()
93 | {
94 | return response()->json(auth()->user());
95 | }
96 |
97 | /**
98 | * Log the user out (Invalidate the token).
99 | *
100 | * @return JsonResponse
101 | */
102 | public function logout()
103 | {
104 | auth()->logout();
105 |
106 | return response()->json(['message' => 'Successfully logged out']);
107 | }
108 |
109 | /**
110 | * Refresh a token.
111 | *
112 | * @return JsonResponse
113 | */
114 | public function refresh()
115 | {
116 | return $this->respondWithToken(auth()->refresh());
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/client/src/public/setwaiting/UpdateDeparture.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Typography, makeStyles, Grid } from '@material-ui/core';
3 | import LinearProgress from '@material-ui/core/LinearProgress';
4 | import Alert from '@material-ui/lab/Alert';
5 | import Button from '@material-ui/core/Button';
6 | import { TimePicker } from '@material-ui/pickers';
7 |
8 | import { updateDeparture } from '../../services';
9 | import { useCaptchaToken } from '../../hooks';
10 | import { useSession } from '../../Session';
11 |
12 | const useStyles = makeStyles({
13 | formFields: {
14 | padding: 10,
15 | margin: '10px 0 20px',
16 | display: 'flex',
17 | justifyContent: 'space-between',
18 | },
19 | fullWidth: {
20 | width: '100%',
21 | },
22 | });
23 |
24 | export function UpdateDeparture() {
25 | const classes = useStyles();
26 | const [isRegistering, setIsRegistering] = useState(false);
27 | const [registerError, setRegisterError] = useState('');
28 | const [session, sessionActions] = useSession();
29 | const [selectedDate, handleDateChange] = useState(new Date());
30 |
31 | const {
32 | isLoading: isCaptchaLoading,
33 | token: recaptchaToken,
34 | refreshCaptchaToken,
35 | } = useCaptchaToken();
36 |
37 | async function handleFormSubmit(evt: React.FormEvent) {
38 | evt.preventDefault();
39 | setIsRegistering(true);
40 | setRegisterError('');
41 | const departure = selectedDate ? selectedDate.getHours() + ':' + selectedDate.getMinutes() : '';
42 | if (!departure) {
43 | setRegisterError('Všetky údaje sú povinné');
44 | return;
45 | }
46 | try {
47 | await updateDeparture(
48 | session.registeredToken?.token || '',
49 | session.registeredToken?.entryId || '',
50 | departure,
51 | recaptchaToken,
52 | );
53 | sessionActions.completeCollectionPoint();
54 | } catch (err) {
55 | refreshCaptchaToken();
56 | setIsRegistering(false);
57 | setRegisterError(
58 | err && err.messageTranslation
59 | ? err.messageTranslation
60 | : 'Chyba pri odosielaní dát, skúste znova neskôr.',
61 | );
62 | }
63 | }
64 | return (
65 | <>
66 | Zadajte čas vášho odchodu
67 |
68 | Údaje o Vašom príchode boli uložené. Nechajte si túto stránku otvorenú a keď dostanete
69 | výsledok testu, zadajte čas vášho odchodu.
70 |
71 |
98 | >
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/client/src/admin/collectionpoints/EditDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField, { TextFieldProps } from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import LinearProgress from '@material-ui/core/LinearProgress';
9 | import Alert from '@material-ui/lab/Alert';
10 | import { CollectionPointEntity, updateCollectionPoint } from '../../services';
11 | import { useSession } from '../../Session';
12 |
13 | const commonInputProps: TextFieldProps = {
14 | type: 'text',
15 | fullWidth: true,
16 | margin: 'dense',
17 | variant: 'outlined',
18 | };
19 |
20 | export function EditDialog({
21 | onCancel,
22 | onConfirm,
23 | entity,
24 | }: React.PropsWithChildren<{
25 | entity?: CollectionPointEntity;
26 | onCancel: () => void;
27 | onConfirm: () => void;
28 | }>) {
29 | const [session] = useSession();
30 | const [state, setState] = useState(entity!);
31 | const [isLoading, setLoading] = useState(false);
32 | const [error, setError] = useState('');
33 |
34 | useEffect(() => {
35 | setState(entity!);
36 | setError('');
37 | }, [entity]);
38 |
39 | async function handleEdit() {
40 | if (!validate()) {
41 | return;
42 | }
43 | setLoading(true);
44 | try {
45 | await updateCollectionPoint(state, session);
46 | onConfirm();
47 | } catch (err) {
48 | setError(err ? JSON.stringify(err) : 'Unexpected error');
49 | } finally {
50 | setLoading(false);
51 | }
52 | }
53 |
54 | function validate() {
55 | for (let key in state) {
56 | if (state.hasOwnProperty(key)) {
57 | const value = state[key as keyof typeof state];
58 | if (value === undefined || value === '') {
59 | setError('All fields are required');
60 | return false;
61 | }
62 | }
63 | }
64 | setError('');
65 | return true;
66 | }
67 |
68 | function handleInputChange(evt: React.ChangeEvent) {
69 | setError('');
70 | setState(prev => ({
71 | ...prev,
72 | [evt.target.name]: evt.target.value,
73 | }));
74 | }
75 |
76 | return (
77 |
78 |
79 | Odberné miesto {state?.address}
80 |
81 |
82 |
89 |
96 |
103 |
110 |
111 | {error && {error} }
112 | {isLoading && }
113 |
114 |
115 | Zrusit
116 |
117 |
118 | Editovat
119 |
120 |
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/api/bootstrap/app.php:
--------------------------------------------------------------------------------
1 | bootstrap();
8 |
9 | date_default_timezone_set(env('APP_TIMEZONE', 'UTC'));
10 |
11 | /*
12 | |--------------------------------------------------------------------------
13 | | Create The Application
14 | |--------------------------------------------------------------------------
15 | |
16 | | Here we will load the environment and create the application instance
17 | | that serves as the central piece of this framework. We'll use this
18 | | application as an "IoC" container and router for this framework.
19 | |
20 | */
21 |
22 | $app = new Laravel\Lumen\Application(
23 | dirname(__DIR__)
24 | );
25 |
26 | $app->withFacades();
27 |
28 | $app->withEloquent();
29 |
30 | /*
31 | |--------------------------------------------------------------------------
32 | | Register Container Bindings
33 | |--------------------------------------------------------------------------
34 | |
35 | | Now we will register a few bindings in the service container. We will
36 | | register the exception handler and the console kernel. You may add
37 | | your own bindings here if you like or you can make another file.
38 | |
39 | */
40 |
41 | $app->singleton(
42 | Illuminate\Contracts\Debug\ExceptionHandler::class,
43 | App\Exceptions\Handler::class
44 | );
45 |
46 | $app->singleton(
47 | Illuminate\Contracts\Console\Kernel::class,
48 | App\Console\Kernel::class
49 | );
50 |
51 | /*
52 | |--------------------------------------------------------------------------
53 | | Register Config Files
54 | |--------------------------------------------------------------------------
55 | |
56 | | Now we will register the "app" configuration file. If the file exists in
57 | | your configuration directory it will be loaded; otherwise, we'll load
58 | | the default version. You may register other files below as needed.
59 | |
60 | */
61 |
62 | $app->configure('app');
63 |
64 | /*
65 | |--------------------------------------------------------------------------
66 | | Register Middleware
67 | |--------------------------------------------------------------------------
68 | |
69 | | Next, we will register the middleware with the application. These can
70 | | be global middleware that run before and after each request into a
71 | | route or middleware that'll be assigned to some specific routes.
72 | |
73 | */
74 |
75 | // $app->middleware([
76 | // App\Http\Middleware\ExampleMiddleware::class
77 | // ]);
78 |
79 | $app->routeMiddleware([
80 | 'auth' => App\Http\Middleware\Authenticate::class,
81 | ]);
82 |
83 | /*
84 | |--------------------------------------------------------------------------
85 | | Register Service Providers
86 | |--------------------------------------------------------------------------
87 | |
88 | | Here we will register all of the application's service providers which
89 | | are used to bind services into the container. Service providers are
90 | | totally optional, so you are not required to uncomment this line.
91 | |
92 | */
93 |
94 | $app->register(App\Providers\AppServiceProvider::class);
95 | // $app->register(App\Providers\EventServiceProvider::class);
96 | $app->register(App\Providers\AuthServiceProvider::class);
97 | $app->register(Tymon\JWTAuth\Providers\LumenServiceProvider::class);
98 |
99 | /*
100 | |--------------------------------------------------------------------------
101 | | Load The Application Routes
102 | |--------------------------------------------------------------------------
103 | |
104 | | Next we will include the routes file so that they can all be added to
105 | | the application. This will provide all of the URLs the application
106 | | can respond to, as well as the controllers that may handle them.
107 | |
108 | */
109 |
110 | $app->router->group([
111 | 'namespace' => 'App\Http\Controllers',
112 | ], function ($router) {
113 | require __DIR__.'/../routes/web.php';
114 | });
115 |
116 | return $app;
117 |
--------------------------------------------------------------------------------
/api/app/Http/Controllers/CollectionPointsController.php:
--------------------------------------------------------------------------------
1 | cache = $cache;
35 | }
36 |
37 | /**
38 | * Refresh cache of a region collection points and return new value
39 | * @param $region
40 | * @return array|Builder[]|Collection
41 | * @throws InvalidArgumentException
42 | */
43 | private function refreshCache($region) {
44 | if (!in_array($region, self::REGIONS)){
45 | return [];
46 | }
47 | $collectionPoint = CollectionPoints::query()
48 | ->where('region', $region)
49 | ->orderBy('county')
50 | ->orderBy('city')
51 | ->orderBy('address')
52 | ->get()->makeHidden('region');
53 | $this->cache->set(self::CACHE_KEY.$region, $collectionPoint);
54 | return $collectionPoint;
55 | }
56 |
57 | /**
58 | * Get actual collection points in a region
59 | * @param $region
60 | * @return Collection|array
61 | * @throws InvalidArgumentException
62 | */
63 | private function getByRegion($region) {
64 | $region = strtoupper($region);
65 | if (!in_array($region, self::REGIONS)){
66 | return [];
67 | }
68 | if (!$this->cache->has(self::CACHE_KEY.$region)) {
69 | return $this->refreshCache($region);
70 | }
71 | return $this->cache->get(self::CACHE_KEY.$region);
72 | }
73 |
74 | /**
75 | * Show all collection points
76 | * @param Request $request
77 | * @return JsonResponse
78 | * @throws InvalidArgumentException
79 | */
80 | public function showAll(Request $request)
81 | {
82 | $collectionPoint = $this->getByRegion($request->get('region'));
83 | return response()->json($collectionPoint);
84 | }
85 |
86 | /**
87 | * Show only admin's allowed collection points
88 | * @return JsonResponse
89 | * @throws InvalidArgumentException
90 | */
91 | public function showMine()
92 | {
93 | /** @var User $user */
94 | $user = auth()->user();
95 | return response()->json($user->collectionPoints()
96 | ->orderBy('region')
97 | ->orderBy('county')
98 | ->orderBy('city')
99 | ->orderBy('address')->getResults()->makeHidden('pivot'));
100 | }
101 |
102 | /**
103 | * @param $id
104 | * @return JsonResponse
105 | */
106 | public function showOne($id)
107 | {
108 | return response()->json(CollectionPoints::query()->findOrFail($id));
109 | }
110 |
111 | /**
112 | * @param $id
113 | * @param Request $request
114 | * @return JsonResponse
115 | * @throws InvalidArgumentException
116 | */
117 | public function updateBreak($id, Request $request)
118 | {
119 | /** @var User $user */
120 | $user = auth()->user();
121 |
122 | /** @var CollectionPoints $collectionPoint */
123 | $collectionPoint = $user->allowedCollectionPoint($id);
124 | if ($collectionPoint === null) {
125 | return $this->forbidden();
126 | }
127 | $collectionPoint->update($request->only(['break_start', 'break_stop', 'break_note']));
128 | $this->refreshCache($collectionPoint->region);
129 | return response()->json($collectionPoint->makeHidden('pivot'), 200);
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/client/src/public/setwaiting/RegisterPlace.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Typography, makeStyles, Grid } from '@material-ui/core';
3 | import LinearProgress from '@material-ui/core/LinearProgress';
4 | import Alert from '@material-ui/lab/Alert';
5 | import Button from '@material-ui/core/Button';
6 | import TextField from '@material-ui/core/TextField';
7 | import { TimePicker } from '@material-ui/pickers';
8 |
9 | import { registerToCollectionPoint } from '../../services';
10 | import { useCaptchaToken } from '../../hooks';
11 | import { useSession } from '../../Session';
12 |
13 | const useStyles = makeStyles({
14 | formFields: {
15 | padding: 10,
16 | margin: '10px 0 20px',
17 | display: 'flex',
18 | justifyContent: 'space-between',
19 | },
20 | fullWidth: {
21 | width: '100%',
22 | },
23 | });
24 |
25 | interface RegisterPlaceProps {
26 | id: string;
27 | county: string;
28 | }
29 |
30 | export function RegisterPlace({ id, county }: RegisterPlaceProps) {
31 | const classes = useStyles();
32 | const [isRegistering, setIsRegistering] = useState(false);
33 | const [registerError, setRegisterError] = useState('');
34 | const [, sessionActions] = useSession();
35 | const [selectedDate, handleDateChange] = useState(new Date());
36 |
37 | const {
38 | isLoading: isCaptchaLoading,
39 | token: recaptchaToken,
40 | refreshCaptchaToken,
41 | } = useCaptchaToken();
42 |
43 | async function handleFormSubmit(evt: React.FormEvent) {
44 | evt.preventDefault();
45 | setRegisterError('');
46 | const form = evt.target as any;
47 | const arrivetime = selectedDate
48 | ? selectedDate.getHours() + ':' + selectedDate.getMinutes()
49 | : '';
50 | const waitingnumber = form.waitingnumber.value;
51 | if (!arrivetime || !waitingnumber) {
52 | setRegisterError('Všetky údaje sú povinné');
53 | return;
54 | }
55 | try {
56 | setIsRegistering(true);
57 | const response = await registerToCollectionPoint(id, {
58 | arrive: arrivetime,
59 | length: waitingnumber,
60 | recaptcha: recaptchaToken,
61 | });
62 | sessionActions.registerToCollectionPoint(
63 | response.token,
64 | String(response.id),
65 | String(response.collection_point_id),
66 | county,
67 | );
68 | } catch (err) {
69 | refreshCaptchaToken();
70 | setIsRegistering(false);
71 | setRegisterError(
72 | err && err.messageTranslation
73 | ? err.messageTranslation
74 | : 'Chyba pri odosielaní dát, skúste znova neskôr.',
75 | );
76 | }
77 | }
78 | return (
79 | <>
80 |
81 | Keď dorazíte na toto odberné miest, zadajte čas vášho príchodu a počet ľudí čakajúcich pred
82 | vami:
83 |
84 | Zadať počet cakajúcich
85 |
121 | >
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/client/src/utils/mock/mocks.ts:
--------------------------------------------------------------------------------
1 | import format from 'date-fns/format';
2 | import isAfter from 'date-fns/isAfter';
3 | import { CollectionPointEntity, CollectionPointEntry } from '../../services';
4 |
5 | const mocks = [
6 | {
7 | urlMath: '/api/collectionpoints/[^/]*/entries',
8 | method: 'GET',
9 | response: () => generateEntries(),
10 | },
11 | {
12 | urlMath: '/api/collectionpoints',
13 | method: 'GET',
14 | response: getCollectionPoints,
15 | },
16 | {
17 | urlMath: '/api/auth/collectionpoints',
18 | method: 'GET',
19 | response: getCollectionPoints,
20 | },
21 | {
22 | urlMath: '/api/collectionpoints/[^/]*/entries',
23 | method: 'POST',
24 | response: () => ({
25 | arrive: '',
26 | length: 10,
27 | collection_point_id: '1',
28 | token: '111',
29 | id: '123',
30 | }),
31 | },
32 | {
33 | urlMath: '/api/entries/',
34 | method: 'PUT',
35 | response: () => ({}),
36 | },
37 | {
38 | urlMath: '/api/login',
39 | method: 'POST',
40 | response: () => ({
41 | token: '123',
42 | token_type: 'Bearer',
43 | expires_in: 3600,
44 | }),
45 | },
46 | {
47 | urlMath: '/api/auth/refresh',
48 | method: 'POST',
49 | response: () => ({
50 | token: '123',
51 | token_type: 'Bearer',
52 | expires_in: 3600,
53 | }),
54 | },
55 | {
56 | urlMath: '/api/collectionpoints/[^/]*/break',
57 | method: 'PUT',
58 | response: () => ({}),
59 | },
60 | ];
61 |
62 | function getCollectionPoints(): CollectionPointEntity[] {
63 | return [
64 | {
65 | id: '1',
66 | county: 'Bratislavsky',
67 | city: 'Bratislava',
68 | region: 'region',
69 | address: 'Bratislava, Stare mesto',
70 | teams: 1,
71 | },
72 | {
73 | id: '2',
74 | county: 'Bratislavsky',
75 | city: 'Bratislava',
76 | region: 'region',
77 | address: 'ZŠ I. Bukovčana 3 ',
78 | teams: 3,
79 | break_start: '10:10',
80 | break_stop: '10:40',
81 | break_note: 'Prestavka pre technicke problemy',
82 | },
83 | {
84 | id: '3',
85 | county: 'Bratislavsky',
86 | city: 'Bratislava',
87 | region: 'region',
88 | address: 'Šport. areál P. Horova 16 ',
89 | teams: 2,
90 | external_system_id: 1,
91 | },
92 | {
93 | id: '4',
94 | county: 'Bratislavsky',
95 | city: 'Bratislava',
96 | region: 'region',
97 | address: 'Vila Košťálová, Novoveská 17 ',
98 | external_system_id: 2,
99 | },
100 | {
101 | id: '5',
102 | county: 'Bratislavsky',
103 | city: 'Bratislava',
104 | region: 'region',
105 | address: 'Istra Centrum, Hradištná 43',
106 |
107 | teams: 4,
108 | },
109 | {
110 | id: '6',
111 | county: 'Bratislavsky',
112 | city: 'Bratislava',
113 | region: 'region',
114 | address: 'Duálna akadémia, J. Jonáša 5',
115 | },
116 | ];
117 | }
118 |
119 | function generateEntries(count = Math.ceil(Math.random() * 40)) {
120 | const template: CollectionPointEntry = {
121 | id: '1',
122 | arrive: '07:10',
123 | departure: '07:10',
124 | token: '123',
125 | length: 10,
126 | verified: 0,
127 | collection_point_id: '0',
128 | admin_note: null,
129 | };
130 | const entries = [];
131 | for (let i = 0; i < count; i++) {
132 | const arrivedSub = Math.ceil(Math.random() * 400000 * (count - i));
133 | const isVerified = i % 6 === 0 ? 1 : 0;
134 | entries.push({
135 | ...template,
136 | id: i,
137 | length: Math.ceil(Math.random() * 100),
138 | arrive: format(new Date(Date.now() - arrivedSub), 'HH:mm'),
139 | departure: format(new Date(Date.now() - arrivedSub + 1000000), 'HH:mm'),
140 | verified: isVerified,
141 | admin_note: isVerified && i % 2 === 0 ? 'Prestavka o 10:15 do 10:45.' : null,
142 | });
143 | }
144 |
145 | return entries.sort((a, b) => {
146 | const firstArrivePair = a.arrive.split(':');
147 | const secondArrivePair = b.arrive.split(':');
148 |
149 | const firstDate = new Date();
150 | firstDate.setHours(Number(firstArrivePair[0]));
151 | firstDate.setMinutes(Number(firstArrivePair[1]));
152 |
153 | const secondDate = new Date();
154 | secondDate.setHours(Number(secondArrivePair[0]));
155 | secondDate.setMinutes(Number(secondArrivePair[1]));
156 |
157 | return isAfter(firstDate, secondDate) ? -1 : 1;
158 | });
159 | }
160 |
161 | export default mocks;
162 |
--------------------------------------------------------------------------------
/api.yaml:
--------------------------------------------------------------------------------
1 | swagger: "2.0"
2 | info:
3 | description: ""
4 | version: "1.2.0"
5 | title: "Som v rade.sk"
6 | contact:
7 | email: "somvrade@gmail.com"
8 | license:
9 | name: "GPL-3.0"
10 | url: "https://github.com/psekan/somvrade/blob/main/LICENSE"
11 | host: "www.somvrade.sk"
12 | basePath: "/api"
13 | tags:
14 | - name: "collection points"
15 | description: "Odberné miesta"
16 | - name: "entry"
17 | description: "Hlásenia"
18 | schemes:
19 | - "https"
20 | paths:
21 | /collectionpoints:
22 | get:
23 | tags:
24 | - "collection points"
25 | summary: "Vyhľadanie odberných miest v kraji"
26 | produces:
27 | - "application/json"
28 | parameters:
29 | - name: "region"
30 | in: "query"
31 | description: "Kraj, pre ktorý majú byť vrátené odberné miesta"
32 | required: true
33 | type: "string"
34 | enum:
35 | - "BA"
36 | - "TT"
37 | - "TN"
38 | - "NR"
39 | - "ZA"
40 | - "BB"
41 | - "KE"
42 | - "PO"
43 | responses:
44 | "200":
45 | description: "successful operation"
46 | schema:
47 | type: "array"
48 | items:
49 | $ref: "#/definitions/Collectionpoint"
50 | /collectionpoints/{id}/entries:
51 | get:
52 | tags:
53 | - "entry"
54 | summary: "Vyhľadanie hlásení aktuálneho dňa odberného miesta"
55 | operationId: "findPetsByStatus"
56 | produces:
57 | - "application/json"
58 | parameters:
59 | - name: "id"
60 | in: "path"
61 | description: "ID oberného miesta"
62 | required: true
63 | type: "integer"
64 | format: "int64"
65 | responses:
66 | "200":
67 | description: "successful operation"
68 | schema:
69 | type: "array"
70 | items:
71 | $ref: "#/definitions/Entry"
72 | definitions:
73 | Collectionpoint:
74 | type: "object"
75 | properties:
76 | id:
77 | type: "integer"
78 | format: "int64"
79 | region:
80 | type: "string"
81 | description: "Kraj"
82 | enum:
83 | - "BA"
84 | - "TT"
85 | - "TN"
86 | - "NR"
87 | - "ZA"
88 | - "BB"
89 | - "KE"
90 | - "PO"
91 | county:
92 | type: "string"
93 | description: "Okres"
94 | city:
95 | type: "string"
96 | description: "Mesto/obec"
97 | address:
98 | type: "string"
99 | description: "Adresa odberného miesta"
100 | teams:
101 | type: "integer"
102 | description: "Počet odberných tímov na odbernom mieste"
103 | external_system_id:
104 | type: "integer"
105 | description: "0 - žiadny, údaje zadávane v našom systéme, 1 - odkaz na odbernemiesta.sk, 2 - vlastný neznámy systém, 3 - vlastný systém na adrese uvedenej v property external_system_link"
106 | enum:
107 | - 0
108 | - 1
109 | - 2
110 | - 3
111 | external_system_link:
112 | type: "string"
113 | description: "Url externého systému odberného miesta"
114 | break_start:
115 | type: "string"
116 | description: "Nullable, čas začiatku prestávky zadanej administrátorom v systéme"
117 | pattern: '^\d{2}:\d{2}:\d{2}$'
118 | break_stop:
119 | type: "string"
120 | description: "Nullable, čas konca prestávky zadanej administrátorom v systéme"
121 | pattern: '^\d{2}:\d{2}:\d{2}$'
122 | break_note:
123 | type: "string"
124 | description: "Poznámka prestávky zadanej administrátorom v systéme"
125 | note:
126 | type: "string"
127 | description: "Poznámka ku odbernému miestu (časy a dátumy otvorenia napríklad)"
128 | Entry:
129 | type: "object"
130 | properties:
131 | arrive:
132 | type: "string"
133 | description: "Čas príchodu"
134 | pattern: '^\d{2}:\d{2}:\d{2}$'
135 | length:
136 | type: "integer"
137 | description: "Dĺžka radu"
138 | departure:
139 | type: "string"
140 | description: "Nullable, čas odchodu"
141 | pattern: '^\d{2}:\d{2}:\d{2}$'
142 | admin_note:
143 | type: "string"
144 | description: "Nullable, poznámka administrátorského hlásenia"
145 | verified:
146 | type: "boolean"
147 | description: "Príznak, či hlásenie bolo zadané administrátorom = dôveryhodné"
148 | externalDocs:
149 | description: "GitHub repository"
150 | url: "https://github.com/psekan/somvrade"
151 |
--------------------------------------------------------------------------------
/client/src/services/index.ts:
--------------------------------------------------------------------------------
1 | import { useFetch } from '../hooks';
2 | import { fetchJson } from '../utils';
3 | import { useSession, Session } from '../Session';
4 |
5 | export interface CollectionPointEntity {
6 | id: string;
7 | county: string;
8 | city: string;
9 | region: string;
10 | address: string;
11 | teams?: number;
12 | external_system_id?: 0 | 1 | 2 | 3;
13 | external_system_link?: string | null;
14 | break_start?: string | null;
15 | break_stop?: string | null;
16 | break_note?: string | null;
17 | note?: string | null;
18 | }
19 |
20 | export function useCollectionPointsPublic(county: string) {
21 | return useFetch(
22 | `/api/collectionpoints?region=${county}`,
23 | undefined,
24 | 300,
25 | );
26 | }
27 |
28 | export interface CollectionPointEntry {
29 | id: string;
30 | collection_point_id: string;
31 | arrive: string;
32 | departure: string;
33 | token: string;
34 | length: number;
35 | verified?: number;
36 | admin_note?: string | null;
37 | }
38 |
39 | export function useCollectionPointEntries(id: string) {
40 | return useFetch(`/api/collectionpoints/${id}/entries`);
41 | }
42 |
43 | export interface RegisterToCollectionPointRequest {
44 | arrive: string;
45 | length: number;
46 | recaptcha: string;
47 | admin_note?: string | null;
48 | }
49 |
50 | export interface RegisterToCollectionPointResponse {
51 | arrive: string;
52 | length: number;
53 | collection_point_id: string;
54 | token: string;
55 | id: number;
56 | }
57 |
58 | export async function registerToCollectionPoint(
59 | collectionPointId: string,
60 | entity: RegisterToCollectionPointRequest,
61 | session?: Session,
62 | ): Promise {
63 | return fetchJson(`/api/collectionpoints/${collectionPointId}/entries`, {
64 | method: 'POST',
65 | body: JSON.stringify(entity),
66 | headers: {
67 | Accept: 'application/json',
68 | 'Content-Type': 'application/json',
69 | ...sessionHeaders(session),
70 | },
71 | });
72 | }
73 |
74 | export async function updateDeparture(
75 | token: string,
76 | id: string,
77 | departure: string,
78 | recaptchaToken: string,
79 | ): Promise {
80 | return fetchJson(`/api/entries/${id}`, {
81 | method: 'PUT',
82 | body: JSON.stringify({ token, departure, recaptcha: recaptchaToken }),
83 | headers: {
84 | Accept: 'application/json',
85 | 'Content-Type': 'application/json',
86 | },
87 | });
88 | }
89 |
90 | /**
91 | * ADMIN
92 | */
93 |
94 | interface LoginResponse {
95 | token: string;
96 | token_type: string;
97 | expires_in: number;
98 | }
99 |
100 | export function login(username: string, password: string): Promise {
101 | let formData = new FormData();
102 | formData.append('email', username);
103 | formData.append('password', password);
104 |
105 | return fetchJson('/api/login', {
106 | method: 'POST',
107 | body: formData,
108 | });
109 | }
110 |
111 | export function refreshToken(token: Session['token']): Promise {
112 | return fetchJson('/api/auth/refresh', {
113 | method: 'POST',
114 | ...withSessionHeaders({ token }),
115 | });
116 | }
117 |
118 | export function useCollectionPointsAdmin() {
119 | const [session] = useSession();
120 | return useFetch(`/api/auth/collectionpoints`, {
121 | method: 'GET',
122 | ...withSessionHeaders(session),
123 | });
124 | }
125 |
126 | export interface BreakRequest {
127 | break_start: string | null;
128 | break_stop: string | null;
129 | break_note?: string | null;
130 | token: string;
131 | }
132 |
133 | export async function setBreak(
134 | id: string,
135 | req: BreakRequest,
136 | session: Session,
137 | ): Promise {
138 | return fetchJson(`/api/collectionpoints/${id}/break`, {
139 | method: 'PUT',
140 | body: JSON.stringify(req),
141 | ...withSessionHeaders(session),
142 | });
143 | }
144 |
145 | export async function updateCollectionPoint(
146 | entity: CollectionPointEntity,
147 | session: Session,
148 | ): Promise {
149 | return fetchJson(`/api/collectionpoints/${entity.id}`, {
150 | method: 'PUT',
151 | body: JSON.stringify(entity),
152 | ...withSessionHeaders(session),
153 | });
154 | }
155 |
156 | function withSessionHeaders(session: { token?: Session['token'] }) {
157 | return {
158 | headers: sessionHeaders(session),
159 | };
160 | }
161 |
162 | function sessionHeaders(session?: { token?: Session['token'] }) {
163 | return (
164 | session && {
165 | Authorization: `${session.token?.tokenType} ${session.token?.accessToken}`,
166 | Accept: 'application/json',
167 | 'Content-Type': 'application/json',
168 | }
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/client/src/public/components/Places.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import TextField from '@material-ui/core/TextField';
3 | import Autocomplete from '@material-ui/lab/Autocomplete';
4 | import { makeStyles } from '@material-ui/core/styles';
5 | import { VariableSizeList, ListChildComponentProps } from 'react-window';
6 | import { Typography } from '@material-ui/core';
7 | import SearchIcon from '@material-ui/icons/Search';
8 | import classNames from 'classnames';
9 | import { CollectionPointEntity, useCollectionPointsPublic } from '../../services';
10 |
11 | const useStyles = makeStyles({
12 | place: {
13 | fontSize: '0.8rem',
14 | },
15 | searchIcon: {
16 | transform: 'rotate(180deg)',
17 | },
18 | });
19 |
20 | interface PlacesProps {
21 | selected?: string;
22 | county: string;
23 | onChange: (collectionPoint: CollectionPointEntity) => void;
24 | label?: string;
25 | size?: 'small';
26 | className?: string;
27 | }
28 |
29 | export function Places({ county, onChange, selected, label, size, className }: PlacesProps) {
30 | const { response, isLoading } = useCollectionPointsPublic(county);
31 | const [searchValue, setSearchValue] = useState('');
32 | const [inputValue, setInputValue] = useState('');
33 | const [isOpen, setOpen] = useState(false);
34 | const classes = useStyles();
35 | const value =
36 | selected && response ? response?.find(it => String(it.id) === String(selected)) : null;
37 |
38 | useEffect(() => {
39 | if (value) {
40 | setInputValue(getTextForSearchInput(value));
41 | }
42 | }, [value]);
43 | return (
44 | {
53 | evt.stopPropagation();
54 | if (value) {
55 | onChange(value);
56 | }
57 | }}
58 | inputValue={inputValue}
59 | onInputChange={(evt, value, reason) => {
60 | if (reason !== 'reset') {
61 | setSearchValue(value);
62 | setInputValue(value);
63 | }
64 | }}
65 | onClose={() => {
66 | if (value) {
67 | setInputValue(getTextForSearchInput(value));
68 | }
69 | setOpen(false);
70 | }}
71 | onOpen={() => {
72 | setInputValue(searchValue);
73 | setOpen(true);
74 | }}
75 | popupIcon={ }
76 | getOptionLabel={option => getTextForSearchInput(option)}
77 | ListboxComponent={ListboxComponent as React.ComponentType>}
78 | renderOption={option => (
79 |
80 | {option.city}
81 |
82 | {modifyPlace(option.address, option.city)}
83 |
84 |
85 | )}
86 | renderInput={params => (
87 |
88 | )}
89 | />
90 | );
91 | }
92 |
93 | function getTextForSearchInput(entity: CollectionPointEntity) {
94 | return `${entity.city}, ${modifyPlace(entity.address, entity.city)}`;
95 | }
96 |
97 | function modifyPlace(place: string, city: string) {
98 | const res = place
99 | .replace(city + ' - ', '')
100 | .replace(city + ', ', '')
101 | .replace(city, '');
102 | return res ? res : place || city;
103 | }
104 |
105 | const LISTBOX_PADDING = 8;
106 |
107 | function renderRow(props: ListChildComponentProps) {
108 | const { data, index, style } = props;
109 | return React.cloneElement(data[index], {
110 | style: {
111 | ...style,
112 | top: (style.top as number) + LISTBOX_PADDING,
113 | },
114 | });
115 | }
116 |
117 | const OuterElementContext = React.createContext({});
118 |
119 | const OuterElementType = React.forwardRef((props, ref) => {
120 | const outerProps = React.useContext(OuterElementContext);
121 | return
;
122 | });
123 |
124 | function useResetCache(data: any) {
125 | const ref = React.useRef(null);
126 | React.useEffect(() => {
127 | if (ref.current != null) {
128 | ref.current.resetAfterIndex(0, true);
129 | }
130 | }, [data]);
131 | return ref;
132 | }
133 |
134 | const ListboxComponent = React.forwardRef(function ListboxComponent(props, ref) {
135 | const { children, ...other } = props;
136 | const itemData = React.Children.toArray(children);
137 | const itemCount = itemData.length;
138 | const itemSize = 70;
139 |
140 | const getChildSize = (child: React.ReactNode) => {
141 | return itemSize;
142 | };
143 |
144 | const getHeight = () => {
145 | if (itemCount > 8) {
146 | return 8 * itemSize;
147 | }
148 | return itemData.map(getChildSize).reduce((a, b) => a + b, 0);
149 | };
150 |
151 | const gridRef = useResetCache(itemCount);
152 |
153 | return (
154 |
155 |
156 | getChildSize(itemData[index])}
164 | overscanCount={5}
165 | itemCount={itemCount}
166 | >
167 | {renderRow}
168 |
169 |
170 |
171 | );
172 | });
173 |
--------------------------------------------------------------------------------
/client/src/admin/collectionpoints/WaitingEntryDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import LinearProgress from '@material-ui/core/LinearProgress';
9 | import Alert from '@material-ui/lab/Alert';
10 | import AddNewEntryIcon from '@material-ui/icons/AddToPhotos';
11 | import { Grid, makeStyles, useMediaQuery, useTheme } from '@material-ui/core';
12 | import { TimePicker } from '@material-ui/pickers';
13 | import { useCaptchaToken } from '../../hooks';
14 | import { CollectionPointEntity, registerToCollectionPoint } from '../../services';
15 | import { useSession } from '../../Session';
16 |
17 | const useStyles = makeStyles({
18 | noteInput: {
19 | marginTop: 20,
20 | },
21 | dialogFooter: {
22 | justifyContent: 'center',
23 | },
24 | });
25 |
26 | interface ModalState {
27 | time?: Date | null;
28 | waitingnumber?: number;
29 | note?: string;
30 | }
31 |
32 | const MAX_NOTE_LENGTH = 500;
33 |
34 | export function WaitingEntryDialog({
35 | onCancel,
36 | onConfirm,
37 | entity,
38 | }: React.PropsWithChildren<{
39 | entity?: CollectionPointEntity;
40 | onCancel: () => void;
41 | onConfirm: () => void;
42 | }>) {
43 | const classes = useStyles();
44 | const [session] = useSession();
45 | const [state, setState] = useState({
46 | time: new Date(),
47 | });
48 | const [isLoading, setLoading] = useState(false);
49 | const [error, setError] = useState('');
50 | const theme = useTheme();
51 | const isMobile = useMediaQuery(theme.breakpoints.down('xs'));
52 |
53 | const { token, refreshCaptchaToken, isLoading: isCaptchaTokenLoading } = useCaptchaToken();
54 |
55 | useEffect(() => {
56 | setState({ time: new Date() });
57 | setError('');
58 | }, [entity]);
59 |
60 | async function handleEdit() {
61 | if (!validate()) {
62 | return;
63 | }
64 | setLoading(true);
65 | try {
66 | await registerToCollectionPoint(
67 | entity?.id!,
68 | {
69 | arrive: formatTime(state.time)!,
70 | length: Number(state.waitingnumber),
71 | admin_note: state.note,
72 | recaptcha: token,
73 | },
74 | session,
75 | );
76 | onConfirm();
77 | } catch (err) {
78 | refreshCaptchaToken();
79 | setError(err && err.message ? String(err.message) : 'Nastala neznáma chyba');
80 | } finally {
81 | setLoading(false);
82 | }
83 | }
84 |
85 | function validate() {
86 | let mandatoryFilled = !!state.time && !!state.waitingnumber;
87 |
88 | if (!mandatoryFilled) {
89 | setError('Čas a počet čakajúcich sú povinné');
90 | return false;
91 | }
92 |
93 | if (state.note && state.note.length > MAX_NOTE_LENGTH) {
94 | setError(`Prekročený maximálny počet znakov (${MAX_NOTE_LENGTH}) pre poznámku`);
95 | return false;
96 | }
97 |
98 | setError('');
99 | return true;
100 | }
101 |
102 | function handleInputChange(evt: React.ChangeEvent) {
103 | setError('');
104 | setState(prev => ({
105 | ...prev,
106 | [evt.target.name]: evt.target.value,
107 | }));
108 | }
109 |
110 | return (
111 |
117 |
118 | Zadať počet čakajúcich pre odberné miesto{' '}
119 |
120 | {entity?.city} {entity?.address}
121 |
122 |
123 |
124 |
125 |
126 |
132 | setState({
133 | ...state,
134 | time,
135 | })
136 | }
137 | minutesStep={5}
138 | fullWidth
139 | />
140 |
141 |
142 |
150 |
151 |
152 |
162 |
163 | {error && {error} }
164 | {(isLoading || isCaptchaTokenLoading) && }
165 |
166 |
167 | Späť
168 |
169 |
175 | Potvrdiť
176 |
177 |
178 |
179 | );
180 | }
181 |
182 | function formatTime(date?: Date | null) {
183 | return date ? date.getHours() + ':' + date.getMinutes() : undefined;
184 | }
185 |
--------------------------------------------------------------------------------
/client/src/public/home/components/PlaceInputForm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles, Theme, createStyles, Grid, TextField, Button, Link } from '@material-ui/core';
3 | import Alert from '@material-ui/lab/Alert';
4 |
5 | const useStyles = makeStyles((theme: Theme) =>
6 | createStyles({
7 | inputs: {
8 | width: '100%',
9 | },
10 | }),
11 | );
12 |
13 | enum formStateType {
14 | Input,
15 | Error,
16 | Success,
17 | WrongInputs
18 | }
19 |
20 | type PlaceInputFormProps = {
21 | onChange: () => void
22 | }
23 |
24 | export default function PlaceInputForm(props: PlaceInputFormProps) {
25 | const classes = useStyles();
26 | const [county, setCounty] = React.useState(null);
27 | const [city, setCity] = React.useState(null);
28 | const [district, setDistrict] = React.useState(null);
29 | const [place, setPlace] = React.useState(null);
30 | const [formState, setFormState] = React.useState(formStateType.Input);
31 |
32 | return (
33 |
34 | {(formState === formStateType.Input || formState === formStateType.Error || formState === formStateType.WrongInputs) && (
35 | <>
36 |
37 | ) => {
43 | setCounty(event.target.value);
44 | }}
45 | />
46 |
47 |
48 | ) => {
54 | setCity(event.target.value);
55 | }}
56 | />
57 |
58 |
59 | ) => {
65 | setDistrict(event.target.value);
66 | }}
67 | />
68 |
69 |
70 | ) => {
76 | setPlace(event.target.value);
77 | }}
78 | />
79 |
80 |
81 | {
85 | if (county && city && district && place &&
86 | county.trim().length !== 0 && city.trim().length !== 0 &&
87 | district.trim().length !== 0 && place.trim().length !== 0) {
88 | fetch('/api/collectionpoints', {
89 | method: 'POST',
90 | headers: {
91 | 'Content-Type': 'application/json',
92 | },
93 | body: JSON.stringify({
94 | county,
95 | city,
96 | district,
97 | place
98 | }),
99 | })
100 | .then(response => {
101 | if (response.status === 201) {
102 | setFormState(formStateType.Success);
103 | props.onChange();
104 | }
105 | else {
106 | setFormState(formStateType.Error);
107 | }
108 | })
109 | .catch((error) => {
110 | setFormState(formStateType.Error);
111 | });
112 | }
113 | else {
114 | setFormState(formStateType.WrongInputs);
115 | }
116 | }}
117 | >
118 | Podať žiadosť
119 |
120 |
121 | {formState === formStateType.WrongInputs && (
122 |
123 | Prosíme, vyplňte všetky polia. Ak vypĺňate informácie za obec,
124 | do pola Časť obce/mesta zadajte opakovane názov obce.
125 |
126 | )}
127 | {formState === formStateType.Error && (
128 |
129 | Ospravedlňujeme sa, ale nastal neznámy problém. Vyskúšajte zopakovať požiadavku
130 | neskôr, alebo nás kontaktovať na somvrade@gmail.com.
131 |
132 | )}
133 | >
134 | )}
135 | {formState === formStateType.Success && (
136 | <>
137 |
138 | Ďakujeme za Vašu žiadosť o pridanie odberného miesta.
139 | Ak viete o ďalších, ktoré ešte v zozname nemáme, prosíme, pridajte aj tie.
140 |
141 |
142 | {
146 | setDistrict('');
147 | setPlace('');
148 | setFormState(formStateType.Input);
149 | }}
150 | >
151 | Pridať ďalšie odberné miesto
152 |
153 |
154 | >
155 | )}
156 |
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/client/src/public/home/components/DuringTestingStepper.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
3 | import Stepper from '@material-ui/core/Stepper';
4 | import Step from '@material-ui/core/Step';
5 | import StepLabel from '@material-ui/core/StepLabel';
6 | import Button from '@material-ui/core/Button';
7 | import Typography from '@material-ui/core/Typography';
8 | import PlaceSelector from "./PlaceSelector";
9 | import {
10 | Grid,
11 | TextField
12 | } from "@material-ui/core";
13 | import {TimePicker} from "@material-ui/pickers";
14 |
15 | const useStyles = makeStyles((theme: Theme) =>
16 | createStyles({
17 | root: {
18 | width: '100%',
19 | },
20 | table: {
21 | width: '100%',
22 | },
23 | fullWidth: {
24 | width: '100%',
25 | },
26 | button: {
27 | marginRight: theme.spacing(1),
28 | },
29 | instructions: {
30 | marginTop: theme.spacing(1),
31 | marginBottom: theme.spacing(1),
32 | },
33 | }),
34 | );
35 |
36 | function getSteps() {
37 | return ['Vyberte odberové miesto', 'Zadajte počet čakajúcich', 'Zadajte čas odchodu'];
38 | }
39 |
40 | export default function DuringTestingStepper() {
41 | const classes = useStyles();
42 | const [activeStep, setActiveStep] = React.useState(0);
43 | const [selectedDate, handleDateChange] = React.useState(new Date());
44 | const steps = getSteps();
45 |
46 | const handleNext = () => {
47 | setActiveStep((prevActiveStep) => prevActiveStep + 1);
48 | };
49 |
50 | const handleBack = () => {
51 | setActiveStep((prevActiveStep) => prevActiveStep - 1);
52 | };
53 |
54 | return (
55 |
56 |
57 | {steps.map((label, index) => {
58 | const stepProps: { completed?: boolean } = {};
59 | const labelProps: { optional?: React.ReactNode } = {};
60 | return (
61 |
62 | {label}
63 |
64 | );
65 | })}
66 |
67 |
68 |
69 |
70 | {activeStep === 0 && (
71 | <>
72 |
73 | V zozname nižšie sa snažte dohľadať odberové miesta, kam ste sa prišli otestovať.
74 | Pokiaľ dané miesto v zozname neexistuje, bude možné ho vytvoriť.
75 |
76 |
77 | >
78 | )}
79 | {activeStep === 1 && (
80 | <>
81 |
82 | Po výbere miesta, budete vyzvaný na zadanie času príchodu a počtu čakajúcich ľudí.
83 | Čas príchodu sa nastaví automaticky na aktuálny čas.
84 |
85 |
86 |
87 |
95 |
96 |
97 |
105 |
106 |
107 | >
108 | )}
109 | {activeStep === 2 && (
110 | <>
111 |
112 | Po získaní certifikátu s výsledkom testu budete môcť informovať ostatných občas, koľko trval celý čas
113 | strávený na danom odberovom mieste. Niektoré miesta budú vykonávať odbery rýchlejšie, iné pomalšie,
114 | preto sa táto informácia môže zísť pre ostatných.
115 |
116 |
117 |
118 |
126 |
127 |
128 |
129 | Nič viac od Vás stránka nebude požadovať, nebude uklada žiadne údaje o Vás. No informácie o časoch a
130 | počte ľudí budú prospešne pre ostatných, ktorí pôjdu na test po Vás.
131 |
132 |
133 | Ďakujeme, že na nich myslíte. Len spoločne si vieme pomôcť.
134 |
135 | >
136 | )}
137 |
138 |
139 | Krok späť
140 |
141 | {activeStep < steps.length - 1 && (
142 |
148 | Nasledujúci krok
149 |
150 | )}
151 |
152 |
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/client/src/public/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, Typography, makeStyles, Theme, createStyles, Grid } from '@material-ui/core';
3 | import CalendarIcon from '@material-ui/icons/Today';
4 | import ClockIcon from '@material-ui/icons/QueryBuilder';
5 | import { NavLink } from '../components/NavLink';
6 | import odberneMiestaLogo from './components/odbernemiesta.sk_logo.png';
7 | import { useSession } from '../../Session';
8 |
9 | const useStyles = makeStyles((theme: Theme) =>
10 | createStyles({
11 | options: {
12 | // background: theme.palette.action.hover,
13 | margin: '20px -26px',
14 | padding: '20px 20px 40px',
15 | },
16 | optionsTitle: {
17 | margin: '0 0 20px',
18 | },
19 | linksContainer: {
20 | display: 'flex',
21 | justifyContent: 'space-between',
22 | margin: '0 -5px',
23 | },
24 | video: {
25 | maxWidth: 700,
26 | width: '95%',
27 | margin: '20px auto',
28 | height: '50vh',
29 | display: 'block',
30 | },
31 | bottomInfo: {
32 | textAlign: 'center',
33 | display: 'block',
34 | fontSize: '1.2rem',
35 | marginBottom: 20,
36 | },
37 | contact: {
38 | textAlign: 'center',
39 | margin: '1.5rem 0',
40 | fontSize: '1rem',
41 | paddingBottom: 40,
42 | },
43 | infoMessage: {
44 | margin: '20px 0px',
45 | display: 'flex',
46 | },
47 | infoMessageIcon: {
48 | verticalAlign: 'bottom',
49 | marginRight: 10,
50 | },
51 | bold: {
52 | fontWeight: 800,
53 | },
54 | odbernieMiestaLogo: {
55 | display: 'block',
56 | textAlign: 'center',
57 | padding: 10,
58 | '& img': {
59 | maxWidth: 300,
60 | },
61 | },
62 | }),
63 | );
64 |
65 | export function HomePage() {
66 | const classes = useStyles();
67 | const [session] = useSession();
68 |
69 | return (
70 |
71 | {/*
*/}
72 | {/* Antigénové testovania*/}
73 | {/* */}
74 |
75 | V prípade, že sa Vaša obec alebo odberné miesto nenachádza v zozname, napíšte nám na emailovú adresu{' '}
76 | somvrade@gmail.com.
77 |
78 |
79 | Zoznam odberných miest je aktualizovaný podľa údajov zverejnených Ministerstvom zdravotníctva.
80 |
81 |
82 |
83 | Vyberte jednu z možností:
84 |
85 |
86 |
87 |
92 |
93 |
94 |
99 |
100 |
101 | it.county + ':' + it.entryId)
104 | .join(',')}`}
105 | label={'Sledované odberné miesta'}
106 | description={'Chcem poznať stav'}
107 | />
108 |
109 |
110 |
111 |
Informácie o testovaní:
112 |
113 |
114 |
115 | Posledné odbery sú vykonávané cca 30 min. pre koncom otváracej doby, z dôvodu vyhodnocovania testov.
116 | Počas sviatkov 24.12. - 26.12.2020, 1.1.2021 a 6.1.2021 budú bezplatné antigénové odberné miesta zatvorené .
117 |
118 |
119 |
120 |
121 |
122 | Prestávka v testovaní - odberné miesta majú obvikle{' '}
123 | obedné prestávky . Ich čas su môžete skontrolovať na stránke {' '}
124 |
125 | Ministerstva zdravotníctva
126 | {' '}
127 | prípadne na našich stránach, po vyhľadaní odberného miesta.
128 |
129 |
130 |
131 |
Ako prebieha testovanie:
132 |
VIDEO
140 |
141 | Aktuálne informácie o antigénovom testovaní nájdete na{' '}
142 |
143 | https://www.health.gov.sk/?ag-mom
144 |
145 |
146 | {/*
*/}
147 | {/* Ak ste nenašli vaše odberné miesto, využite partnerskú službu na:*/}
148 | {/* */}
149 | {/*
*/}
154 | {/*
*/}
155 | {/**/}
156 |
157 | );
158 | }
159 |
--------------------------------------------------------------------------------
/client/src/public/home/components/BeforeTestingStepper.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
3 | import Stepper from '@material-ui/core/Stepper';
4 | import Step from '@material-ui/core/Step';
5 | import StepLabel from '@material-ui/core/StepLabel';
6 | import Button from '@material-ui/core/Button';
7 | import Typography from '@material-ui/core/Typography';
8 | import PlaceSelector from "./PlaceSelector";
9 | import {Grid, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Link} from "@material-ui/core";
10 |
11 | const useStyles = makeStyles((theme: Theme) =>
12 | createStyles({
13 | root: {
14 | width: '100%',
15 | },
16 | table: {
17 | width: '100%',
18 | },
19 | button: {
20 | marginRight: theme.spacing(1),
21 | },
22 | instructions: {
23 | marginTop: theme.spacing(1),
24 | marginBottom: theme.spacing(1),
25 | },
26 | }),
27 | );
28 |
29 | function getSteps() {
30 | return ['Vyberte odberové miesto', 'Skontrolujte aktuálnu situáciu', 'Vyberte sa ku odberovému miestu'];
31 | }
32 |
33 | interface DataType {
34 | arrive: string,
35 | waiting: number,
36 | departure: string
37 | }
38 |
39 | const data: DataType[] = [
40 | {arrive: '11:25', waiting: 15, departure: '-'},
41 | {arrive: '10:40', waiting: 20, departure: '11:45'},
42 | {arrive: '10:30', waiting: 10, departure: '11:35'}
43 | ];
44 |
45 | export default function BeforeTestingStepper() {
46 | const classes = useStyles();
47 | const [activeStep, setActiveStep] = React.useState(0);
48 | const steps = getSteps();
49 |
50 |
51 | const handleNext = () => {
52 | setActiveStep((prevActiveStep) => prevActiveStep + 1);
53 | };
54 |
55 | const handleBack = () => {
56 | setActiveStep((prevActiveStep) => prevActiveStep - 1);
57 | };
58 |
59 | return (
60 |
61 |
62 | {steps.map((label, index) => {
63 | const stepProps: { completed?: boolean } = {};
64 | const labelProps: { optional?: React.ReactNode } = {};
65 | return (
66 |
67 | {label}
68 |
69 | );
70 | })}
71 |
72 |
73 |
74 |
75 | {activeStep === 0 && (
76 | <>
77 |
78 | V zozname nižšie sa snažte dohľadať odberové miesta, kam sa chcete ísť otestovať.
79 | Pokiaľ dané miesto v zozname neexistuje, bude možné ho vytvoriť.
80 |
81 |
82 | >
83 | )}
84 | {activeStep === 1 && (
85 | <>
86 |
87 | Následne uvidíte tabuľku s informáciami od ostatných, ktorí už boli pred Vami,
88 | kedy sa postavili do radu na vybranom odberovom mieste, aký dlhý rad stál pred nimi
89 | a ak už dostali výsledok a chceli informovať aj dĺžke celého testovania, tak aj čas kedy opustili
90 | odberové miesto s výsledkom testu.
91 |
92 |
93 |
94 |
95 |
96 | Čas príchodu
97 | Počet čakajúcich
98 | Čas odchodu
99 |
100 |
101 |
102 | {data.map((row) => (
103 |
104 |
105 | {row.arrive}
106 |
107 | {row.waiting}
108 | {row.departure}
109 |
110 | ))}
111 |
112 |
113 |
114 |
115 | Ak to bude v našich možnostiach a budeme mať dostatok dát, budeme sa snažiť vypočítavať čas,
116 | kedy Vám odporúčame prísť na testovanie, tak aby ste v rade nečakali dlho, ale zároveň, aby
117 | na odberovom mieste mali v každom čase dostatok ľudí na testovanie.
118 |
119 | >
120 | )}
121 | {activeStep === 2 && (
122 | <>
123 |
124 | Podľa zobrazených informácií od iných občanov, prípadne od odporúčania nami vypočítaného času,
125 | sa rozhodnite, kedy pôjdete ku odberovému miestu. Nezabudnite započítať aj čas, ktorý Vám bude
126 | cesta k nemu trvať.
127 |
128 |
129 | Môžete pokračovať na nasledujúcu sekciu Na odberovom mieste.
130 |
131 | >
132 | )}
133 |
134 |
135 | Krok späť
136 |
137 | {activeStep < steps.length - 1 && (
138 |
144 | Nasledujúci krok
145 |
146 | )}
147 |
148 |
149 |
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/client/src/Session.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useMemo, useEffect } from 'react';
2 | import { refreshToken } from './services';
3 |
4 | const defaultSession: Session = {
5 | isLoggedIn: false,
6 | };
7 | const currentDate = new Date();
8 | const STORAGE_KEY = '@somvrade';
9 | const STORAGE_KEY_COL_POINT_TOKEN = `@somvrade_cl_p_token_${currentDate.getFullYear()}_${currentDate.getMonth()}_${currentDate.getDay()}`;
10 | const STORAGE_KEY_FAVORITES = '@somvrade_favorites';
11 |
12 | const initialActions: SessionContextActions = {
13 | initSecureSession: () => null,
14 | destroySession: () => null,
15 | registerToCollectionPoint: () => null,
16 | completeCollectionPoint: () => null,
17 | setFavorite: () => null,
18 | };
19 |
20 | export interface Session {
21 | isLoggedIn: boolean;
22 | token?: Token;
23 | isRegistered?: boolean;
24 | registeredToken?: {
25 | token: string;
26 | entryId: string;
27 | collectionPointId: string;
28 | county: string;
29 | completed: boolean;
30 | };
31 | favorites?: Array<{ county: string; entryId: string }>;
32 | }
33 |
34 | type SessionContextType = [Session, SessionContextActions];
35 |
36 | export interface Token {
37 | accessToken: string;
38 | tokenType: string;
39 | expiresIn: number;
40 | }
41 |
42 | interface SessionContextActions {
43 | initSecureSession: (token: Token) => void;
44 | registerToCollectionPoint: (
45 | token: string,
46 | entityId: string,
47 | collectionPointId: string,
48 | county: string,
49 | ) => void;
50 | completeCollectionPoint: () => void;
51 | destroySession: () => void;
52 | setFavorite: (county: string, entryId: string) => void;
53 | }
54 |
55 | const SessionContext = React.createContext([defaultSession, initialActions]);
56 |
57 | export function SessionContextProvider({ children }: React.PropsWithChildren<{}>) {
58 | const [state, setState] = useState({ ...defaultSession, ...restoreSession() });
59 |
60 | useEffect(() => {
61 | let timeout: any;
62 |
63 | if (state.token) {
64 | const runRefreshIn = state.token.expiresIn - Date.now();
65 | //eslint-disable-next-line
66 | console.log('token valid', runRefreshIn / 1000, 'seconds');
67 |
68 | const destroSession = () =>
69 | setState(prev => {
70 | sessionStorage.removeItem(STORAGE_KEY);
71 | return { ...prev, token: undefined, isLoggedIn: false };
72 | });
73 |
74 | if (runRefreshIn < 0) {
75 | destroSession();
76 | return;
77 | }
78 |
79 | timeout = setTimeout(() => {
80 | //eslint-disable-next-line
81 | console.log('refreshing token');
82 | refreshToken(state.token)
83 | .then(resp =>
84 | setState(prev => ({
85 | ...prev,
86 | token: {
87 | accessToken: resp.token,
88 | tokenType: resp.token_type,
89 | expiresIn: new Date(Date.now() + (resp.expires_in - 60) * 1000).getTime(),
90 | },
91 | })),
92 | )
93 | .catch(destroSession);
94 | }, runRefreshIn);
95 | }
96 | return () => {
97 | if (timeout) {
98 | clearTimeout(timeout);
99 | }
100 | };
101 | }, [state.token]);
102 |
103 | const sessionContext = useMemo(() => {
104 | return [
105 | state,
106 | {
107 | initSecureSession: token => {
108 | token.expiresIn = new Date(Date.now() + (token.expiresIn - 60) * 1000).getTime();
109 | sessionStorage.setItem(STORAGE_KEY, JSON.stringify(token));
110 | setState({ ...state, isLoggedIn: true, token });
111 | },
112 | destroySession: () => {
113 | sessionStorage.removeItem(STORAGE_KEY);
114 | setState({ ...defaultSession });
115 | },
116 | registerToCollectionPoint: (token, entryId, collectionPointId, county) => {
117 | const registeredObj = {
118 | token,
119 | entryId,
120 | collectionPointId,
121 | completed: false,
122 | county,
123 | };
124 | localStorage.setItem(STORAGE_KEY_COL_POINT_TOKEN, JSON.stringify(registeredObj));
125 | setState({ ...state, isRegistered: true, registeredToken: registeredObj });
126 | },
127 | completeCollectionPoint: () => {
128 | const newRegistrationToken = { ...state.registeredToken, completed: true };
129 | localStorage.setItem(STORAGE_KEY_COL_POINT_TOKEN, JSON.stringify(newRegistrationToken));
130 | setState({
131 | ...state,
132 | isRegistered: true,
133 | registeredToken: newRegistrationToken as any,
134 | });
135 | },
136 | setFavorite: (county, entryId) => {
137 | const exists = state.favorites?.some(
138 | it => it.county === county && it.entryId === entryId,
139 | );
140 | const newState = exists
141 | ? state.favorites?.filter(it => it.county !== county || it.entryId !== entryId)
142 | : [...(state.favorites || []), { county, entryId }];
143 | localStorage.setItem(STORAGE_KEY_FAVORITES, JSON.stringify(newState));
144 | setState({
145 | ...state,
146 | favorites: newState,
147 | });
148 | },
149 | },
150 | ];
151 | }, [state]);
152 |
153 | return {children} ;
154 | }
155 |
156 | export function useSession() {
157 | return useContext(SessionContext);
158 | }
159 |
160 | function restoreSession(): Session | undefined {
161 | try {
162 | const restored: Session = {} as any;
163 | const tokenFromStorage = sessionStorage.getItem(STORAGE_KEY);
164 | const restoredSessionToken: Token = tokenFromStorage ? JSON.parse(tokenFromStorage) : {};
165 | const isAdminLoggedId = restoredSessionToken.accessToken && restoredSessionToken.tokenType;
166 | if (isAdminLoggedId) {
167 | restored.isLoggedIn = true;
168 | restored.token = restoredSessionToken;
169 | }
170 |
171 | const registeredCollectionPointToken = localStorage.getItem(STORAGE_KEY_COL_POINT_TOKEN);
172 | const parsedRegisteredCollectionPointToken =
173 | registeredCollectionPointToken && JSON.parse(registeredCollectionPointToken);
174 |
175 | if (registeredCollectionPointToken) {
176 | restored.isRegistered = true;
177 | restored.registeredToken = parsedRegisteredCollectionPointToken;
178 | }
179 |
180 | const favoritesFromStorage = localStorage.getItem(STORAGE_KEY_FAVORITES);
181 | const parsedFavorites = favoritesFromStorage && JSON.parse(favoritesFromStorage);
182 | if (favoritesFromStorage) {
183 | restored.favorites = parsedFavorites;
184 | }
185 |
186 | return restored;
187 | } catch {
188 | return undefined;
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/client/src/admin/collectionpoints/CollectionPoints.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import {
3 | Table,
4 | TableBody,
5 | TableCell,
6 | TableContainer,
7 | TableRow,
8 | makeStyles,
9 | TableHead,
10 | IconButton,
11 | TablePagination,
12 | Snackbar,
13 | } from '@material-ui/core';
14 | import Alert from '@material-ui/lab/Alert';
15 | import LinearProgress from '@material-ui/core/LinearProgress';
16 | import AddNewEntryIcon from '@material-ui/icons/AddToPhotos';
17 | import ClockIcon from '@material-ui/icons/QueryBuilder';
18 | import TextField from '@material-ui/core/TextField';
19 | import SearchIcon from '@material-ui/icons/SearchOutlined';
20 | import InfoIcon from '@material-ui/icons/ListAlt';
21 | import { matchSorter } from 'match-sorter';
22 | import { WaitingEntryDialog } from './WaitingEntryDialog';
23 | import { SetBreakDialog } from './SetBreakDialog';
24 | import { EntryDetailDialog } from './EntryDetailDialog';
25 | import { useCollectionPointsAdmin, CollectionPointEntity } from '../../services';
26 |
27 | const useStyles = makeStyles(theme => ({
28 | container: {
29 | padding: 0,
30 | },
31 | table: {
32 | width: '100%',
33 | },
34 | rowActions: {
35 | display: 'flex',
36 | justifyContent: 'flex-end',
37 | flexWrap: 'wrap',
38 | },
39 | searchInput: {
40 | margin: '10px 0',
41 | padding: '10px',
42 | },
43 | breakInfo: {
44 | color: theme.palette.primary.main,
45 | },
46 | breakInfoIcon: {
47 | verticalAlign: 'bottom',
48 | },
49 | }));
50 |
51 | type CollectionPointsProps = {
52 | onlyWaiting: boolean;
53 | };
54 |
55 | const DEFAULT_PAGE_SIZE = 10;
56 |
57 | export function CollectionPoints(props: CollectionPointsProps) {
58 | const classes = useStyles();
59 | const [dialogEntity, setDialogEditingEntity] = useState<{
60 | entity: CollectionPointEntity;
61 | dialog: 'break' | 'addentry' | 'detail';
62 | }>();
63 | const { isLoading, response, error, refresh } = useCollectionPointsAdmin();
64 | const [filter, setFilter] = useState('');
65 | const [page, setPage] = useState(0);
66 | const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_PAGE_SIZE);
67 | const [successMessage, setSuccessMessage] = useState('');
68 |
69 | const handleChangePage = (event: unknown, newPage: number) => {
70 | setPage(newPage);
71 | };
72 |
73 | const handleChangeRowsPerPage = (event: React.ChangeEvent) => {
74 | setRowsPerPage(parseInt(event.target.value, 10));
75 | setPage(0);
76 | };
77 |
78 | const data = matchSorter(response || [], filter, {
79 | keys: ['county', 'city', 'district', 'address'],
80 | });
81 |
82 | const pagedData = data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
83 |
84 | const pagination = (
85 |
95 | );
96 |
97 | return (
98 | <>
99 |
100 | {isLoading && }
101 | {error && {JSON.stringify(error)} }
102 |
103 | ,
110 | }}
111 | onChange={evt => {
112 | setFilter(evt.target.value);
113 | setPage(0);
114 | }}
115 | />
116 |
117 | {pagination}
118 |
119 |
120 |
121 | Odberné miesto
122 | Možnosti
123 |
124 |
125 |
126 | {pagedData.map(row => (
127 |
131 | setDialogEditingEntity({
132 | entity,
133 | dialog: 'addentry',
134 | })
135 | }
136 | handleBreak={entity =>
137 | setDialogEditingEntity({
138 | entity,
139 | dialog: 'break',
140 | })
141 | }
142 | handleDetail={entity =>
143 | setDialogEditingEntity({
144 | entity,
145 | dialog: 'detail',
146 | })
147 | }
148 | />
149 | ))}
150 |
151 |
152 | {pagination}
153 |
154 | setDialogEditingEntity(undefined)}
157 | onConfirm={() => {
158 | setDialogEditingEntity(undefined);
159 | setSuccessMessage('Vaše údaje boli úspešne uložené.');
160 | refresh();
161 | }}
162 | />
163 | setDialogEditingEntity(undefined)}
166 | onConfirm={() => {
167 | setDialogEditingEntity(undefined);
168 | setSuccessMessage('Vaše údaje boli úspešne uložené.');
169 | refresh();
170 | }}
171 | />
172 | setDialogEditingEntity(undefined)}
175 | />
176 | setSuccessMessage('')}
180 | >
181 | setSuccessMessage('')}>
182 | {successMessage}
183 |
184 |
185 | >
186 | );
187 | }
188 |
189 | function Row({
190 | entity,
191 | handleBreak,
192 | handleAddEntry,
193 | handleDetail,
194 | }: {
195 | entity: CollectionPointEntity;
196 | handleBreak: (entity: CollectionPointEntity) => void;
197 | handleAddEntry: (entity: CollectionPointEntity) => void;
198 | handleDetail: (entity: CollectionPointEntity) => void;
199 | }) {
200 | const classes = useStyles();
201 |
202 | return (
203 |
204 |
205 | {entity.county}
206 |
207 | {entity.region}
208 |
209 | {entity.city}
210 |
211 | {entity.address}
212 | {entity.break_start ? (
213 |
214 |
215 |
216 | Prestávka do {entity.break_stop}
217 |
218 | ) : (
219 | ''
220 | )}
221 |
222 |
223 |
224 |
handleAddEntry(entity)}
228 | >
229 |
230 |
231 |
232 |
handleBreak(entity)}
236 | >
237 |
238 |
239 |
handleDetail(entity)}
243 | >
244 |
245 |
246 |
247 |
248 |
249 | );
250 | }
251 |
--------------------------------------------------------------------------------
/api/app/Http/Controllers/EntryController.php:
--------------------------------------------------------------------------------
1 | cache = $cache;
40 | }
41 |
42 | /**
43 | * Refresh the cache of a collection point's entries
44 | * @param $id
45 | * @return Builder[]|Collection
46 | * @throws InvalidArgumentException
47 | */
48 | private function refreshCache($id) {
49 | $entries = Entry::query()
50 | ->where('collection_point_id', $id)
51 | ->where('day', date('Y-m-d'))
52 | ->orderBy('arrive', 'desc')
53 | ->limit(100)
54 | ->get()->makeHidden(['token', 'collection_point_id']);
55 | $this->cache->set(self::CACHE_KEY.$id, $entries);
56 | return $entries;
57 | }
58 |
59 | /**
60 | * Get cached entries for a collection point
61 | * @param $id
62 | * @return Collection|array
63 | * @throws InvalidArgumentException
64 | */
65 | private function getByPoint($id) {
66 | if (!$this->cache->has(self::CACHE_KEY.$id)) {
67 | return $this->refreshCache($id);
68 | }
69 | return $this->cache->get(self::CACHE_KEY.$id);
70 | }
71 |
72 | /**
73 | * Return true if a user is authenticated and is capable to modify the collection point
74 | * @param $id
75 | * @return bool
76 | */
77 | private function isUserAdmin($id) {
78 | if (auth()->check()) {
79 | /** @var User $user */
80 | $user = auth()->user();
81 | $collectionPoint = $user->allowedCollectionPoint($id);
82 | if ($collectionPoint !== null) {
83 | return true;
84 | }
85 | }
86 | return false;
87 | }
88 |
89 | /**
90 | * @return string
91 | */
92 | private function generateToken() {
93 | $token = openssl_random_pseudo_bytes(16);
94 | return bin2hex($token);
95 | }
96 |
97 | private function captchaNotValid() {
98 | return response()->json([
99 | 'messageTranslation' => 'Nepodarilo sa nám overiť užívateľa. Prosíme, otvorte stránku ešte raz.'
100 | ], 401);
101 | }
102 |
103 | /**
104 | * Get entries for a collection point
105 | * @param $id
106 | * @return JsonResponse
107 | * @throws InvalidArgumentException
108 | */
109 | public function showAll($id)
110 | {
111 | return response()->json($this->getByPoint($id));
112 | }
113 |
114 | /**
115 | * Create an entry
116 | * If authenticated user is an admin for this point, create verified entry with admin note
117 | * @param $id
118 | * @param Request $request
119 | * @return JsonResponse
120 | * @throws InvalidArgumentException
121 | * @throws ValidationException
122 | */
123 | public function create($id, Request $request)
124 | {
125 | $this->validate($request, [
126 | 'arrive' => 'required',
127 | 'length' => 'required',
128 | 'recaptcha' => 'required'
129 | ]);
130 |
131 | $verified = false;
132 | $adminNote = '';
133 | $isAdmin = $this->isUserAdmin($id);
134 | if ($isAdmin) {
135 | $verified = true;
136 | $adminNote = $request->get('admin_note', '');
137 | }
138 |
139 | if ($this->verifyCaptcha($request->get('recaptcha')) != true) {
140 | return $this->captchaNotValid();
141 | }
142 | if (strtotime($request->get('arrive')) > time()+self::ALLOWED_EARLIER_SUBMIT && !$isAdmin) {
143 | return response()->json(['messageTranslation' => 'Nesprávne zadaný časový údaj.'], 400);
144 | }
145 |
146 | $entry = Entry::query()->create($request->merge([
147 | 'day' => date('Y-m-d'),
148 | 'collection_point_id' => $id,
149 | 'token' => $this->generateToken(),
150 | 'verified' => $verified,
151 | 'admin_note' => $adminNote
152 | ])->only(['collection_point_id', 'day', 'arrive', 'length', 'admin_note', 'verified', 'token']));
153 | $this->refreshCache($id);
154 | return response()->json($entry, 201);
155 | }
156 |
157 | /**
158 | * @param $eid
159 | * @param Request $request
160 | * @return JsonResponse
161 | * @throws InvalidArgumentException
162 | * @throws ValidationException
163 | */
164 | public function update($eid, Request $request)
165 | {
166 | $this->validate($request, [
167 | 'recaptcha' => 'required',
168 | ]);
169 |
170 | if ($this->verifyCaptcha($request->get('recaptcha')) != true) {
171 | return $this->captchaNotValid();
172 | }
173 |
174 | $entry = Entry::query()->findOrFail($eid);
175 | $collectionPointId = $entry->collection_point_id;
176 |
177 | $verified = $entry->verified;
178 | $adminNote = $entry->admin_note;
179 | $isAdmin = $this->isUserAdmin($collectionPointId);
180 | if ($isAdmin) {
181 | $verified = true;
182 | $adminNote = $request->get('admin_note', $entry->admin_note);
183 | }
184 | else {
185 | $this->validate($request, [
186 | 'token' => 'required',
187 | 'departure' => 'required'
188 | ]);
189 | $departureTime = strtotime($request->get('departure'));
190 | if ($departureTime <= strtotime($entry->arrive)+self::MIN_DURATION_ON_POINT ||
191 | $departureTime > time()+self::ALLOWED_EARLIER_SUBMIT) {
192 | return response()->json(['messageTranslation' => 'Nesprávne zadaný časový údaj.'], 400);
193 | }
194 | if ($entry->token != $request->get('token')) {
195 | return response()->json(['messageTranslation' => 'Nedostatočné oprávnenie. Vaše zariadenie nedisponuje platným prístup na úpravu zvoleného času.'], 403);
196 | }
197 | }
198 | $entry->update($request->merge([
199 | 'verified' => $verified,
200 | 'admin_note' => $adminNote
201 | ])->only('departure', 'admin_note', 'verified'));
202 | $this->refreshCache($collectionPointId);
203 | return response()->json($entry, 200);
204 | }
205 |
206 | /**
207 | * @param $eid
208 | * @param Request $request
209 | * @return JsonResponse
210 | * @throws InvalidArgumentException
211 | * @throws Exception
212 | */
213 | public function delete($eid, Request $request)
214 | {
215 | $entry = Entry::query()->findOrFail($eid);
216 | $collectionPointId = $entry->collection_point_id;
217 | if ($entry->token != $request->get('token', '-') &&
218 | !$this->isUserAdmin($collectionPointId)) {
219 | return response()->json(['message' => 'Unauthorized'], 401);
220 | }
221 | $entry->delete();
222 | $this->refreshCache($collectionPointId);
223 | return response()->json(['message' => 'Deleted Successfully.'], 200);
224 | }
225 |
226 | /**
227 | * @param $token
228 | * @return bool
229 | */
230 | private function verifyCaptcha($token) {
231 | $secret = env('RECAPTCHA', '');
232 | if ($secret === 'disabled') {
233 | return true;
234 | }
235 | if ($secret === '') {
236 | Log::critical('reCAPTCHA is not configured');
237 | return false;
238 | }
239 | $recaptcha = new ReCaptcha($secret);
240 | $resp = $recaptcha->verify($token);
241 | return $resp->isSuccess();
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/client/src/admin/collectionpoints/SetBreakDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import Button from '@material-ui/core/Button';
3 | import TextField from '@material-ui/core/TextField';
4 | import Dialog from '@material-ui/core/Dialog';
5 | import DialogActions from '@material-ui/core/DialogActions';
6 | import DialogContent from '@material-ui/core/DialogContent';
7 | import DialogTitle from '@material-ui/core/DialogTitle';
8 | import LinearProgress from '@material-ui/core/LinearProgress';
9 | import Alert from '@material-ui/lab/Alert';
10 | import ClockIcon from '@material-ui/icons/QueryBuilder';
11 | import { ButtonGroup, Grid, makeStyles, useMediaQuery, useTheme } from '@material-ui/core';
12 | import { TimePicker } from '@material-ui/pickers';
13 | import { CollectionPointEntity, setBreak, BreakRequest } from '../../services';
14 | import { useSession } from '../../Session';
15 | import { useCaptchaToken } from '../../hooks';
16 |
17 | const useStyles = makeStyles({
18 | noteInput: {
19 | marginTop: 20,
20 | },
21 | dialogFooter: {
22 | justifyContent: 'center',
23 | },
24 | dialogCancelBreakButtons: {
25 | textAlign: 'center',
26 | marginTop: 20,
27 | display: 'flex',
28 | justifyContent: 'center',
29 | },
30 | });
31 |
32 | interface ModalState {
33 | breakStart?: Date | null;
34 | breakStop?: Date | null;
35 | note?: string;
36 | }
37 |
38 | const MAX_NOTE_LENGTH = 500;
39 |
40 | export function SetBreakDialog({
41 | onCancel,
42 | onConfirm,
43 | entity,
44 | }: React.PropsWithChildren<{
45 | entity?: CollectionPointEntity;
46 | onCancel: () => void;
47 | onConfirm: () => void;
48 | }>) {
49 | const classes = useStyles();
50 | const [session] = useSession();
51 | const [state, setState] = useState(getInitialState(entity));
52 | const [isLoading, setLoading] = useState(false);
53 | const [error, setError] = useState('');
54 | const [editingBreak, setEditingBreak] = useState(!entity?.break_start);
55 | const theme = useTheme();
56 | const isMobile = useMediaQuery(theme.breakpoints.down('xs'));
57 |
58 | const { token, refreshCaptchaToken, isLoading: isCaptchaTokenLoading } = useCaptchaToken();
59 |
60 | useEffect(() => {
61 | setState(getInitialState(entity));
62 | setError('');
63 | setEditingBreak(!entity?.break_start);
64 | }, [entity]);
65 |
66 | function handleEdit(evt: React.FormEvent) {
67 | evt.stopPropagation();
68 | evt.preventDefault();
69 |
70 | if (!validate()) {
71 | return;
72 | }
73 | sendBreakData({
74 | break_start: formatTime(state.breakStart)!,
75 | break_stop: formatTime(state.breakStop)!,
76 | break_note: state.note,
77 | token,
78 | });
79 | }
80 |
81 | function handleBreakCancel(evt: React.FormEvent) {
82 | evt.stopPropagation();
83 | evt.preventDefault();
84 | sendBreakData({
85 | break_start: null,
86 | break_stop: null,
87 | break_note: null,
88 | token,
89 | });
90 | }
91 |
92 | async function sendBreakData(breakReq: BreakRequest) {
93 | setLoading(true);
94 | try {
95 | await setBreak(entity?.id!, breakReq, session);
96 | onConfirm();
97 | } catch (err) {
98 | setError(err && err.message ? String(err.message) : 'Nastala neznáma chyba');
99 | refreshCaptchaToken();
100 | } finally {
101 | setLoading(false);
102 | }
103 | }
104 |
105 | function validate() {
106 | let mandatoryFilled = !!state.breakStart && !!state.breakStop;
107 |
108 | if (!mandatoryFilled) {
109 | setError('Začiatok a koniec prestávky sú povinné');
110 | return false;
111 | }
112 |
113 | if (state.note && state.note.length > MAX_NOTE_LENGTH) {
114 | setError(`Prekročený maximálny počet znakov (${MAX_NOTE_LENGTH}) pre poznámku`);
115 | return false;
116 | }
117 |
118 | setError('');
119 | return true;
120 | }
121 |
122 | function handleInputChange(evt: React.ChangeEvent) {
123 | setError('');
124 | setState(prev => ({
125 | ...prev,
126 | [evt.target.name]: evt.target.value,
127 | }));
128 | }
129 |
130 | return (
131 |
137 |
138 | Zadať prestávku pre odberné miesto{' '}
139 |
140 | {entity?.city} {entity?.address}
141 |
142 |
143 |
144 | {editingBreak ? (
145 | <>
146 |
147 |
148 |
154 | setState({
155 | ...state,
156 | breakStart: time,
157 | })
158 | }
159 | minutesStep={5}
160 | fullWidth
161 | />
162 |
163 |
164 |
170 | setState({
171 | ...state,
172 | breakStop: time,
173 | })
174 | }
175 | minutesStep={5}
176 | fullWidth
177 | />
178 |
179 |
180 |
190 |
191 | Informácia o prestávke sa používateľom zobrazí ihneď po jej odoslaní.
192 |
193 | >
194 | ) : (
195 |
196 |
197 | Pre vybrané odberné miesto je prestávka už zadaná. Chcete ju upraviť alebo zrusiť?
198 |
199 |
200 |
206 | Zrušiť prestávku
207 |
208 | setEditingBreak(true)}
210 | color="default"
211 | variant={'contained'}
212 | disabled={isLoading || isCaptchaTokenLoading}
213 | >
214 | Upraviť prestávku
215 |
216 |
217 |
218 | )}
219 |
220 | {error && {error} }
221 | {(isLoading || isCaptchaTokenLoading) && }
222 |
223 |
224 |
225 | Späť
226 |
227 | {editingBreak && (
228 |
234 | Potvrdiť
235 |
236 | )}
237 |
238 |
239 | );
240 | }
241 |
242 | function getInitialState(entity?: CollectionPointEntity): ModalState {
243 | return {
244 | breakStart: parseTime(entity?.break_start) || new Date(),
245 | breakStop: parseTime(entity?.break_start) || new Date(),
246 | };
247 | }
248 |
249 | function formatTime(date?: Date | null) {
250 | return date ? date.getHours() + ':' + date.getMinutes() : undefined;
251 | }
252 |
253 | function parseTime(time?: string | null) {
254 | if (time) {
255 | const now = new Date();
256 | const pair = time.split(':').map(it => Number(it));
257 | now.setHours(pair[0]);
258 | now.setMinutes(pair[1]);
259 | return now;
260 | }
261 | return undefined;
262 | }
263 |
--------------------------------------------------------------------------------
/client/src/public/components/PlaceDetail.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useHistory, Link as RouterLink } from 'react-router-dom';
3 | import PlaceIcon from '@material-ui/icons/Place';
4 | import { Typography, makeStyles, Badge, Chip } from '@material-ui/core';
5 | import LinearProgress from '@material-ui/core/LinearProgress';
6 | import Alert from '@material-ui/lab/Alert';
7 | import Button from '@material-ui/core/Button';
8 | import IconButton from '@material-ui/core/IconButton';
9 | import FaceOutlinedIcon from '@material-ui/icons/BookmarkBorder';
10 | import FavoriteIcon from '@material-ui/icons/Bookmark';
11 | import ClockIcon from '@material-ui/icons/QueryBuilder';
12 | import Avatar from '@material-ui/core/Avatar';
13 | import { AlertTitle } from '@material-ui/lab';
14 |
15 | import {
16 | useCollectionPointsPublic,
17 | useCollectionPointEntries,
18 | CollectionPointEntity,
19 | } from '../../services';
20 | import { Places } from '../components/Places';
21 | import { CollectionEntries } from '../components/CollectionEntries';
22 | import { useSession } from '../../Session';
23 | import { MAX_FAVORITES } from '../../constants';
24 | import { SocialButtons } from '../components/SocialButtons';
25 | import { OdberneMiesta, DefaultExternal, OnlineBooking } from './ExternalPartners';
26 |
27 | const useStyles = makeStyles({
28 | placeTitle: {
29 | fontStyle: 'italic',
30 | fontSize: '1.2rem',
31 | lineHeight: '1.2rem',
32 | '& a': {
33 | textDecoration: 'none',
34 | color: 'inherit',
35 | },
36 | },
37 | placesSelect: {
38 | margin: '20px 0',
39 | },
40 | table: {
41 | marginBottom: 20,
42 | },
43 | locationContainer: {
44 | display: 'flex',
45 | justifyContent: 'space-between',
46 | alignItems: 'center',
47 | },
48 | teamWrapper: {
49 | display: 'flex',
50 | justifyContent: 'space-between',
51 | marginBottom: 10,
52 | alignItems: 'center',
53 | },
54 | alertBreakTitle: {
55 | fontSize: '0.9rem',
56 | margin: 0,
57 | },
58 | });
59 |
60 | interface PlaceDetailProps {
61 | county: string;
62 | id: string;
63 | showSearch?: boolean;
64 | limitTable?: number;
65 | className?: string;
66 | showSocialButtons?: boolean;
67 | adminView?: boolean;
68 | }
69 |
70 | export function PlaceDetail({
71 | county,
72 | id,
73 | showSearch,
74 | limitTable,
75 | className,
76 | showSocialButtons,
77 | adminView,
78 | }: PlaceDetailProps) {
79 | const classes = useStyles();
80 | const history = useHistory();
81 | const { isLoading, response, error, refresh } = useCollectionPointsPublic(county);
82 | const detail = response?.find(it => String(it.id) === String(id));
83 | const [session, sessionActions] = useSession();
84 | return (
85 |
86 | {showSearch && (
87 |
history.push(`/aktualne-pocty-cakajucich/${county}/${entity.id}`)}
94 | />
95 | )}
96 | {isLoading && }
97 | {!isLoading && error && }
98 | {!detail && !error && !isLoading && (
99 | it.county === county && it.entryId === id) && (
103 | sessionActions.setFavorite(county, id)}
107 | >
108 | Vymaž zo sledovaných
109 |
110 | )
111 | }
112 | >
113 | Odberné miesto nenájdene
114 |
115 | )}
116 | {!isLoading && detail && (
117 | <>
118 |
134 | ),
135 | },
136 | {
137 | case: 1,
138 | component: ,
139 | },
140 | {
141 | case: 2,
142 | component: ,
143 | },
144 | {
145 | case: 3,
146 | component: ,
147 | },
148 | ]}
149 | />
150 | >
151 | )}
152 |
153 | );
154 | }
155 |
156 | function PlaceDetailTable({
157 | detail,
158 | county,
159 | id,
160 | limitTable,
161 | showSocialButtons,
162 | adminView,
163 | }: { detail: CollectionPointEntity } & PlaceDetailProps) {
164 | const classes = useStyles();
165 |
166 | const [session, sessionActions] = useSession();
167 | const { isLoading, response, error, refresh } = useCollectionPointEntries(detail.id);
168 | return (
169 | <>
170 | {!isLoading && error && }
171 | {isLoading && (
172 | <>
173 |
174 |
175 | >
176 | )}
177 | {!isLoading && (
178 |
179 |
180 |
181 | {!adminView && (
182 |
183 | {session.favorites?.some(it => it.county === county && it.entryId === id) ? (
184 | sessionActions.setFavorite(county, id)}
186 | title={'Odstrániť zo sledovaných odberných miest'}
187 | >
188 |
189 |
190 | ) : (
191 | 0
194 | ? MAX_FAVORITES - session.favorites.length
195 | : MAX_FAVORITES
196 | }
197 | color="primary"
198 | overlap="circle"
199 | >
200 | sessionActions.setFavorite(county, id)}
202 | title={'Pridať do sledovaných odberných miest'}
203 | color="primary"
204 | disabled={
205 | session.favorites ? session.favorites.length >= MAX_FAVORITES : false
206 | }
207 | >
208 |
209 |
210 |
211 | )}
212 |
213 | )}
214 |
215 | {/*
*/}
216 | {/* {detail.teams || '?'}}*/}
220 | {/* label={'Počet odberných tímov'}*/}
221 | {/* color={'primary'}*/}
222 | {/* />*/}
223 | {/*
*/}
224 | {detail.break_start && (
225 |
}>
226 |
227 | Nahlásená prestávka - {detail.break_start} do {detail.break_stop}
228 |
229 | {detail.break_note || ''}
230 |
231 | )}
232 | {detail.note && (
233 |
}>
234 |
235 | Prevádzkové hodiny
236 |
237 | {detail.note}
238 |
239 | )}
240 |
241 | {!session.isRegistered && !adminView && (
242 |
249 | Sem sa idem testovať
250 |
251 | )}
252 |
253 | )}
254 | {showSocialButtons && !adminView && }
255 | >
256 | );
257 | }
258 |
259 | interface ConditionalRenderProps {
260 | value: T;
261 | items: Array<{
262 | case?: T;
263 | default?: boolean;
264 | component: React.ReactNode;
265 | }>;
266 | }
267 |
268 | function ConditionalRender({ items, value }: ConditionalRenderProps) {
269 | let component = items.find(it => it.case === value);
270 | component = component || items.find(it => it.default);
271 | return <>{component ? component.component || null : null}>;
272 | }
273 |
274 | function PlaceName({
275 | detail,
276 | county,
277 | id,
278 | adminView,
279 | }: {
280 | detail: CollectionPointEntity;
281 | county: string;
282 | id: string;
283 | adminView?: boolean;
284 | }) {
285 | const classes = useStyles();
286 | return (
287 |
288 | {' '}
289 | {adminView ? (
290 | detail.address
291 | ) : (
292 | {detail.address}
293 | )}
294 |
295 | );
296 | }
297 |
298 | function ErrorHandler({ refresh }: { refresh: () => void }) {
299 | return (
300 |
304 | Obnoviť
305 |
306 | }
307 | >
308 | Nastala neznáma chyba
309 |
310 | );
311 | }
312 |
--------------------------------------------------------------------------------
/client/src/public/components/CollectionEntries.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { makeStyles, Typography, useTheme, useMediaQuery } from '@material-ui/core';
3 | import Table from '@material-ui/core/Table';
4 | import TableBody from '@material-ui/core/TableBody';
5 | import TableCell from '@material-ui/core/TableCell';
6 | import TableContainer from '@material-ui/core/TableContainer';
7 | import TableHead from '@material-ui/core/TableHead';
8 | import TableRow from '@material-ui/core/TableRow';
9 | import Paper from '@material-ui/core/Paper';
10 | import Button from '@material-ui/core/Button';
11 | import ArrowDown from '@material-ui/icons/ArrowDownward';
12 | import ArrowUp from '@material-ui/icons/ArrowUpward';
13 | import InfoIcon from '@material-ui/icons/InfoRounded';
14 | import CheckCircleIcon from '@material-ui/icons/CheckCircle';
15 | import MessageIcon from '@material-ui/icons/Message';
16 | import Backdrop from '@material-ui/core/Backdrop';
17 | import isAfter from 'date-fns/isAfter';
18 | import setMinutes from 'date-fns/setMinutes';
19 | import setHours from 'date-fns/setHours';
20 | import classNames from 'classnames';
21 | import { CollectionPointEntry } from '../../services';
22 |
23 | const useStyles = makeStyles(theme => ({
24 | headerCell: {
25 | fontSize: '0.8rem',
26 | lineHeight: '0.8rem',
27 | },
28 | countInfo: {
29 | textAlign: 'center',
30 | margin: '20px 0',
31 | },
32 | messageBackdrop: {
33 | zIndex: 999,
34 | color: '#fff',
35 | background: 'rgba(0,0,0, 0.9)',
36 | },
37 | messageContent: {
38 | maxWidth: 500,
39 | textAlign: 'center',
40 | padding: 20,
41 | },
42 | messageContentAdditional: {
43 | marginTop: 20,
44 | background: '#FFF',
45 | color: '#000',
46 | borderRadius: 20,
47 | padding: 20,
48 | },
49 | infoIconSmall: {
50 | fontSize: 13,
51 | cursor: 'pointer',
52 | verticalAlign: 'middle',
53 | },
54 | infoIconLarge: {
55 | fontSize: 60,
56 | marginBottom: 20,
57 | },
58 | verified: {
59 | background: theme.palette.primary.light,
60 | transition: 'background 0.3s',
61 | cursor: 'pointer',
62 |
63 | '&:hover': {
64 | background: theme.palette.secondary.light,
65 | },
66 | },
67 | adminVerifiedColInfo: {
68 | fontSize: '0.6rem',
69 | lineHeight: '1em',
70 | },
71 | }));
72 |
73 | interface CollectionEntriesProps {
74 | className?: string;
75 | limitTable?: number;
76 | maxItemsCollapsedMobile?: number;
77 | maxItemsCollapsedDesktop?: number;
78 | data: CollectionPointEntry[] | undefined;
79 | }
80 |
81 | const VALUES_FOR_MEDIAN = 10;
82 |
83 | export function CollectionEntries({
84 | className,
85 | limitTable,
86 | data,
87 | maxItemsCollapsedMobile = 5,
88 | maxItemsCollapsedDesktop = 10,
89 | }: CollectionEntriesProps) {
90 | const classes = useStyles();
91 | const [tableCollabsed, setTableCollapsed] = useState(true);
92 | const [infoMessage, setInfoMessage] = useState<{
93 | message: string;
94 | additionalInfo?: string | null;
95 | }>();
96 | const dataToDisplay = (limitTable ? data?.slice(0, limitTable) : data) || [];
97 | const dataSize = (dataToDisplay || []).length;
98 | const theme = useTheme();
99 | const isMobile = useMediaQuery(theme.breakpoints.down('xs'));
100 | const maxItemsCollapsed = isMobile ? maxItemsCollapsedMobile : maxItemsCollapsedDesktop;
101 | const waiting = countWaiting(data || []);
102 |
103 | return (
104 |
105 | {dataSize > 0 && (
106 |
107 |
110 | setInfoMessage({
111 | message: `Počet čakajúcich sa snažíme zobraziť vždy podľa posledného záznamu od administratívneho pracovníka z danného odberného miesta za poslednú hodinu.
112 | Ak takýto záznam neexistuje, vypočítavame ho na základe údajov od ostatných používateľov.`,
113 | })
114 | }
115 | >
116 | Približný počet čakajúcich
117 |
118 |
119 |
120 |
121 |
122 | Posledná aktualizácia: {dataToDisplay[0].arrive}
123 |
124 |
125 | )}
126 |
127 |
128 |
129 |
130 | Návštevník prišiel o:
131 |
132 | Počet osôb pred ním:
133 |
134 |
135 | Návštevník odišiel o:
136 |
137 |
138 |
139 |
140 | {dataToDisplay.slice(0, tableCollabsed ? maxItemsCollapsed : dataSize).map(row => (
141 | {
145 | if (row.verified !== 0) {
146 | setInfoMessage({
147 | message: `Záznam uložený administratívnym pracovníkom priamo z daného odberného miesta.`,
148 | additionalInfo: row.admin_note,
149 | });
150 | }
151 | }}
152 | >
153 |
154 | {formatTime(row.arrive)}{' '}
155 | {row.verified !== 0 ? (
156 |
157 | ) : null}
158 | {row.admin_note && }
159 |
160 |
161 | {row.length}
162 |
163 |
169 | {row.verified !== 0
170 | ? 'Zadané administrátorom'
171 | : formatTime(row.departure) || 'Čaká sa'}
172 |
173 |
174 | ))}
175 | {dataSize === 0 && (
176 |
177 |
178 | O tomto odbernom mieste zatiaľ nemáme žiadne informácie.
179 |
180 |
181 | )}
182 |
183 |
184 | {dataSize > maxItemsCollapsed && (
185 | setTableCollapsed(!tableCollabsed)}
190 | >
191 | {tableCollabsed ? 'Zobraziť všetko' : 'Schovať staršie'}
192 | {tableCollabsed ? : }
193 |
194 | )}
195 |
196 |
setInfoMessage(undefined)}
200 | >
201 |
202 |
203 |
{infoMessage?.message}
204 | {infoMessage?.additionalInfo && (
205 |
206 | Dotatočná informácia:
207 | {infoMessage?.additionalInfo}
208 |
209 | )}
210 |
211 |
212 |
213 | );
214 | }
215 |
216 | function Colored({ count, additonalText }: { count: number; additonalText?: string }) {
217 | const theme = useTheme();
218 | const colorsMapping = [
219 | {
220 | color: theme.palette.primary.main,
221 | range: [0, 50],
222 | },
223 | {
224 | color: theme.palette.warning.dark,
225 | range: [50, 80],
226 | },
227 | {
228 | color: theme.palette.error.dark,
229 | range: [80, Infinity],
230 | },
231 | ];
232 | const color = colorsMapping.find(c => count >= c.range[0] && count <= c.range[1]);
233 | return (
234 |
235 | {count} {additonalText}
236 |
237 | );
238 | }
239 |
240 | function formatTime(time: string) {
241 | if (time) {
242 | if (time.length === 8) {
243 | time = time.substring(0, 5);
244 | }
245 | if (time.charAt(0) === '0') {
246 | time = time.substring(1);
247 | }
248 | }
249 | return time;
250 | }
251 |
252 | function countWaiting(data: CollectionPointEntry[]) {
253 | const valueForMedian = data.slice(0, VALUES_FOR_MEDIAN);
254 |
255 | let addedWithinHour = getWithinHour(data, true);
256 | if (addedWithinHour.length) {
257 | // if there are some verified items then don't count median and return the first one
258 | return addedWithinHour[0].length;
259 | }
260 | // otherwise get items from last hour
261 | addedWithinHour = getWithinHour(data);
262 |
263 | return Math.ceil(
264 | median((addedWithinHour.length ? addedWithinHour : valueForMedian).map(it => it.length)),
265 | );
266 | }
267 |
268 | function getWithinHour(data: CollectionPointEntry[], onlyVerified?: boolean) {
269 | const hourBefore = new Date(Date.now() - 3600000);
270 | const now = new Date();
271 | return data
272 | .filter(it => !onlyVerified || it.verified !== 0)
273 | .filter(it => {
274 | const hourMinutesPair = it.arrive.split(':');
275 | const timeAdded = setHours(
276 | setMinutes(now, Number(hourMinutesPair[1])),
277 | Number(hourMinutesPair[0]),
278 | );
279 | return isAfter(timeAdded, hourBefore);
280 | });
281 | }
282 |
283 | function median(values: number[]) {
284 | if (values.length === 0) {
285 | return 0;
286 | }
287 |
288 | values.sort(function (a, b) {
289 | return a - b;
290 | });
291 |
292 | var half = Math.floor(values.length / 2);
293 |
294 | if (values.length % 2) {
295 | return values[half];
296 | }
297 |
298 | return (values[half - 1] + values[half]) / 2.0;
299 | }
300 |
--------------------------------------------------------------------------------