├── api ├── tests │ └── .gitkeep ├── resources │ └── views │ │ └── .gitkeep ├── app │ ├── Console │ │ ├── Commands │ │ │ └── .gitkeep │ │ └── Kernel.php │ ├── Providers │ │ ├── AppServiceProvider.php │ │ └── AuthServiceProvider.php │ ├── Models │ │ ├── CollectionPoints.php │ │ ├── Entry.php │ │ └── User.php │ ├── Http │ │ ├── Controllers │ │ │ ├── Controller.php │ │ │ ├── AuthController.php │ │ │ ├── CollectionPointsController.php │ │ │ └── EntryController.php │ │ └── Middleware │ │ │ └── Authenticate.php │ └── Exceptions │ │ └── Handler.php ├── database │ ├── migrations │ │ ├── .gitkeep │ │ ├── 2020_10_29_234043_create_users_table.php │ │ ├── 2020_11_06_125023_create_collection_points_users_table.php │ │ ├── 2020_10_30_140739_create_entry_table.php │ │ └── 2020_10_28_232632_create_collection_points_table.php │ └── seeders │ │ └── DatabaseSeeder.php ├── storage │ ├── app │ │ └── .gitignore │ ├── logs │ │ └── .gitignore │ └── framework │ │ ├── views │ │ └── .gitignore │ │ └── cache │ │ ├── data │ │ └── .gitignore │ │ └── .gitignore ├── .gitignore ├── README.md ├── .env.example ├── config │ ├── auth.php │ └── cache.php ├── public │ ├── .htaccess │ └── index.php ├── artisan ├── composer.json ├── routes │ └── web.php └── bootstrap │ └── app.php ├── .gitignore ├── client ├── src │ ├── react-app-env.d.ts │ ├── public │ │ ├── favorites │ │ │ ├── index.ts │ │ │ └── Favorites.tsx │ │ ├── checkwaiting │ │ │ ├── index.tsx │ │ │ ├── PlaceDetail.tsx │ │ │ └── CheckWaiting.tsx │ │ ├── setwaiting │ │ │ ├── index.tsx │ │ │ ├── SetWaiting.tsx │ │ │ ├── PlaceRegister.tsx │ │ │ ├── UpdateDeparture.tsx │ │ │ └── RegisterPlace.tsx │ │ ├── home │ │ │ ├── components │ │ │ │ ├── odbernemiesta.sk_logo.png │ │ │ │ ├── PlaceType.ts │ │ │ │ ├── PlacesContext.ts │ │ │ │ ├── PlaceSelector.tsx │ │ │ │ ├── PlaceInputForm.tsx │ │ │ │ ├── DuringTestingStepper.tsx │ │ │ │ └── BeforeTestingStepper.tsx │ │ │ └── HomePage.tsx │ │ ├── components │ │ │ ├── BackToStartLink.tsx │ │ │ ├── Container.tsx │ │ │ ├── SearchPlace.tsx │ │ │ ├── TextLink.tsx │ │ │ ├── CountyLinks.tsx │ │ │ ├── Header.tsx │ │ │ ├── NavLink.tsx │ │ │ ├── ExternalPartners.tsx │ │ │ ├── SocialButtons.tsx │ │ │ ├── Places.tsx │ │ │ ├── PlaceDetail.tsx │ │ │ └── CollectionEntries.tsx │ │ ├── notfound │ │ │ └── index.tsx │ │ └── index.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useFetch.ts │ │ └── useCaptchaToken.ts │ ├── admin │ │ ├── collectionpoints │ │ │ ├── index.ts │ │ │ ├── EntryDetailDialog.tsx │ │ │ ├── EditDialog.tsx │ │ │ ├── WaitingEntryDialog.tsx │ │ │ ├── CollectionPoints.tsx │ │ │ └── SetBreakDialog.tsx │ │ ├── index.tsx │ │ ├── home │ │ │ └── AdminHomePage.tsx │ │ └── login │ │ │ └── Login.tsx │ ├── constants.ts │ ├── utils │ │ ├── mock │ │ │ ├── index.ts │ │ │ └── mocks.ts │ │ └── index.ts │ ├── App.tsx │ ├── index.tsx │ ├── theme.ts │ ├── services │ │ └── index.ts │ └── Session.tsx ├── public │ ├── FB.png │ ├── google4a19c43954531744.html │ ├── favicon.ico │ ├── logo256.png │ ├── logo512.png │ ├── robots.txt │ ├── .htaccess │ ├── manifest.json │ └── index.html ├── .prettierrc ├── .gitignore ├── tsconfig.json └── package.json ├── README.md └── api.yaml /api/tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | -------------------------------------------------------------------------------- /api/app/Console/Commands/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/database/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/public/favorites/index.ts: -------------------------------------------------------------------------------- 1 | export { Favorites } from './Favorites'; 2 | -------------------------------------------------------------------------------- /client/public/FB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psekan/somvrade/HEAD/client/public/FB.png -------------------------------------------------------------------------------- /client/public/google4a19c43954531744.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google4a19c43954531744.html -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useFetch'; 2 | export * from './useCaptchaToken'; 3 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psekan/somvrade/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psekan/somvrade/HEAD/client/public/logo256.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psekan/somvrade/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/admin/collectionpoints/index.ts: -------------------------------------------------------------------------------- 1 | export { CollectionPoints } from './CollectionPoints'; 2 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | Homestead.json 4 | Homestead.yaml 5 | .env 6 | .phpunit.result.cache 7 | /config/cache.php 8 | -------------------------------------------------------------------------------- /client/src/public/checkwaiting/index.tsx: -------------------------------------------------------------------------------- 1 | export { CheckWaiting } from './CheckWaiting'; 2 | export { PlaceDetailPage } from './PlaceDetail'; 3 | -------------------------------------------------------------------------------- /client/src/public/setwaiting/index.tsx: -------------------------------------------------------------------------------- 1 | export { SetWaiting } from './SetWaiting'; 2 | export { PlaceRegister } from '../setwaiting/PlaceRegister'; 3 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "semi": true, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /client/src/public/home/components/odbernemiesta.sk_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psekan/somvrade/HEAD/client/src/public/home/components/odbernemiesta.sk_logo.png -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Som v rade.sk API 2 | 3 | Pri nasadení na novú inštanciu: 4 | 5 | - pridať `.env` podľa `.env.example` 6 | - `php artisan jwt:secret` 7 | - `php artisan migrate` 8 | -------------------------------------------------------------------------------- /client/src/public/components/BackToStartLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TextLink } from './TextLink'; 3 | 4 | export function BackToStartLink({ center }: { center?: boolean }) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/public/home/components/PlaceType.ts: -------------------------------------------------------------------------------- 1 | export interface PlaceType { 2 | inputValue?: string; 3 | id?: number; 4 | county: string; 5 | city: string; 6 | district: string; 7 | place: string; 8 | created_at?: string; 9 | updated_at?: string; 10 | } 11 | -------------------------------------------------------------------------------- /client/public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteBase / 4 | RewriteRule ^index\.html$ - [L] 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | RewriteCond %{REQUEST_FILENAME} !-l 8 | RewriteRule . /index.html [L] 9 | -------------------------------------------------------------------------------- /client/src/public/notfound/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TextLink } from '../components/TextLink'; 3 | 4 | export function NotFound() { 5 | return ( 6 |
7 | Prepáčte ale táto stránka neexistuje. 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client/src/public/home/components/PlacesContext.ts: -------------------------------------------------------------------------------- 1 | import { PlaceType } from './PlaceType'; 2 | import React from 'react'; 3 | 4 | export type PlacesContextProps = { 5 | error: string|null, 6 | places: PlaceType[] 7 | } 8 | 9 | export const PlacesContext = React.createContext({error: null, places: []}); 10 | -------------------------------------------------------------------------------- /api/database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | call('UsersTableSeeder'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=somvrade.sk 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | APP_TIMEZONE=Europe/Bratislava 7 | 8 | LOG_CHANNEL=stack 9 | LOG_SLACK_WEBHOOK_URL= 10 | 11 | DB_CONNECTION=mysql 12 | DB_HOST=127.0.0.1 13 | DB_PORT=3306 14 | DB_DATABASE=somvrade 15 | DB_USERNAME=somvrade 16 | DB_PASSWORD=CHANGEME 17 | 18 | CACHE_DRIVER=file 19 | QUEUE_CONNECTION=sync 20 | 21 | RECAPTCHA=disabled -------------------------------------------------------------------------------- /api/config/auth.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'guard' => 'api', 6 | 'passwords' => 'users', 7 | ], 8 | 9 | 'guards' => [ 10 | 'api' => [ 11 | 'driver' => 'jwt', 12 | 'provider' => 'users', 13 | ], 14 | ], 15 | 16 | 'providers' => [ 17 | 'users' => [ 18 | 'driver' => 'eloquent', 19 | 'model' => \App\Models\User::class 20 | ] 21 | ] 22 | ]; 23 | -------------------------------------------------------------------------------- /api/app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | ) { 13 | const classes = useStyles(); 14 | const location = useLocation(); 15 | return ( 16 |
17 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /client/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_FAVORITES = 10; 2 | 3 | export const COUNTY = [ 4 | { 5 | id: 'BA', 6 | name: 'Bratislavský kraj', 7 | }, 8 | { 9 | id: 'TT', 10 | name: 'Trnavský kraj', 11 | }, 12 | { 13 | id: 'TN', 14 | name: 'Trenčiansky kraj', 15 | }, 16 | { 17 | id: 'NR', 18 | name: 'Nitriansky kraj', 19 | }, 20 | { 21 | id: 'ZA', 22 | name: 'Žilinský kraj', 23 | }, 24 | { 25 | id: 'BB', 26 | name: 'Banskobystrický kraj', 27 | }, 28 | { 29 | id: 'PO', 30 | name: 'Prešovský kraj', 31 | }, 32 | { 33 | id: 'KE', 34 | name: 'Košický kraj', 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /api/app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Handle Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /client/src/public/components/SearchPlace.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | import { Places } from '../components/Places'; 4 | import { CollectionPointEntity } from '../../services'; 5 | 6 | interface SearchPlaceProps { 7 | county: string; 8 | onSelect: (entity: CollectionPointEntity) => void; 9 | } 10 | 11 | export function SearchPlace({ county, onSelect }: SearchPlaceProps) { 12 | return ( 13 |
14 | 15 | Vyhľadajte odberné miesto, kde sa chcete ísť dať otestovať: 16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /client/src/public/components/TextLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, makeStyles } from '@material-ui/core'; 3 | import { Link as RouterLink } from 'react-router-dom'; 4 | import classNames from 'classnames'; 5 | 6 | const useStyles = makeStyles(theme => ({ 7 | link: { 8 | display: 'block', 9 | marginTop: 20, 10 | color: theme.palette.primary.main, 11 | }, 12 | center: { 13 | textAlign: 'center', 14 | }, 15 | })); 16 | 17 | export function TextLink({ to, text, center }: { to: string; text: string; center?: boolean }) { 18 | const classes = useStyles(); 19 | return ( 20 | 21 | {text} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /api/app/Models/CollectionPoints.php: -------------------------------------------------------------------------------- 1 | { 12 | history.replace(session.isLoggedIn ? '/admin' : '/admin/login'); 13 | }, [history, session]); 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | {session.isLoggedIn && ( 21 | 22 | 23 | 24 | )} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /api/database/migrations/2020_10_29_234043_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('email')->unique()->index(); 19 | $table->string('password'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('users'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/utils/mock/index.ts: -------------------------------------------------------------------------------- 1 | import mocks from './mocks'; 2 | 3 | export default async function mock(url: string, options?: RequestInit) { 4 | const method = options?.method || 'GET'; 5 | console.log('MOCK Req:', method, url, options?.body); 6 | 7 | const mockObj = mocks.find(m => m.method === method && new RegExp(m.urlMath).test(url)); 8 | 9 | await new Promise(resolve => setTimeout(resolve, 1000)); 10 | 11 | if (!mockObj) { 12 | console.log('MOCK Res:', 'Mock not found'); 13 | return Promise.resolve({ 14 | ok: false, 15 | json: () => Promise.resolve({ message: 'Mock not found' }), 16 | }); 17 | } 18 | 19 | return Promise.resolve({ 20 | ok: true, 21 | json: () => { 22 | const response = mockObj.response(); 23 | console.log('MOCK Res:', JSON.stringify(response)); 24 | return Promise.resolve(response); 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /client/src/public/checkwaiting/PlaceDetail.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | import { useParams } from 'react-router-dom'; 4 | import { PlaceDetail } from '../components/PlaceDetail'; 5 | import { TextLink } from '../components/TextLink'; 6 | import { BackToStartLink } from '../components/BackToStartLink'; 7 | 8 | export function PlaceDetailPage() { 9 | const { county, id } = useParams<{ county: string; id: string }>(); 10 | 11 | return ( 12 |
13 | 14 | Aktuálne počty čakajúcich 15 | 16 | 17 | 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export async function fetchJson(url: string, options?: RequestInit, cacheTime?: number) { 2 | const cacheKey = (options?.method || 'GET') + url; 3 | try { 4 | let cached = localStorage.getItem(cacheKey); 5 | if (cached) { 6 | const obj = JSON.parse(cached); 7 | if (obj.validity > Date.now()) { 8 | return Promise.resolve(obj.data); 9 | } 10 | } 11 | } catch { 12 | // noop 13 | } 14 | 15 | const fetchFn = 16 | process.env.REACT_APP_USE_MOCK === 'true' ? (await import('./mock')).default : fetch; 17 | 18 | const resp = await fetchFn(url, options); 19 | const json = await resp.json(); 20 | if (resp.ok) { 21 | if (cacheTime) { 22 | localStorage.setItem( 23 | cacheKey, 24 | JSON.stringify({ data: json, validity: Date.now() + cacheTime * 1000 }), 25 | ); 26 | } 27 | 28 | return json; 29 | } 30 | return Promise.reject(json); 31 | } 32 | -------------------------------------------------------------------------------- /api/app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | json([ 22 | 'token' => $token, 23 | 'token_type' => 'bearer', 24 | 'expires_in' => Auth::factory()->getTTL() * 60 25 | ], 200); 26 | } 27 | 28 | protected function unauthorized() { 29 | return response()->json(['message' => 'Unauthorized'], 401); 30 | } 31 | 32 | protected function forbidden() { 33 | return response()->json(['message' => 'Forbidden'], 403); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/public/index.php: -------------------------------------------------------------------------------- 1 | run(); 29 | -------------------------------------------------------------------------------- /api/config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_DRIVER', 'memcached'), 7 | 'stores' => [ 8 | 'memcached' => [ 9 | 'driver' => 'memcached', 10 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 11 | 'sasl' => [ 12 | env('MEMCACHED_USERNAME'), 13 | env('MEMCACHED_PASSWORD'), 14 | ], 15 | 'options' => [ 16 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 17 | ], 18 | 'servers' => [ 19 | [ 20 | 'host' => env('MEMCACHED_HOST', ''), 21 | 'port' => env('MEMCACHED_PORT', 11211), 22 | 'weight' => 100, 23 | ], 24 | ], 25 | ], 26 | 'file' => [ 27 | 'driver' => 'file', 28 | 'path' => storage_path('framework/cache/data'), 29 | ], 30 | ], 31 | 'prefix' => env( 32 | 'CACHE_PREFIX', 33 | Str::slug(env('APP_NAME', 'lumen'), '_').'_cache' 34 | ), 35 | ]; 36 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 3 | import { Link, Typography, makeStyles } from '@material-ui/core'; 4 | import { Public } from './public'; 5 | import { Admin } from './admin'; 6 | 7 | const useStyles = makeStyles({ 8 | contact: { 9 | margin: '60px 0 80px', 10 | textAlign: 'center', 11 | fontSize: '0.9rem', 12 | lineHeight: '1.5em', 13 | }, 14 | }); 15 | 16 | export default function App() { 17 | const classes = useStyles(); 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Zaznamenali ste chybu, nenašli ste odberné miesto, alebo máte iný dotaz? Prosím, napíšte nám 32 | na somvrade@gmail.com. 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Container, CssBaseline } from '@material-ui/core'; 4 | import { ThemeProvider } from '@material-ui/core/styles'; 5 | import { MuiPickersUtilsProvider } from '@material-ui/pickers'; 6 | import DateFnsUtils from '@date-io/date-fns'; 7 | import App from './App'; 8 | import theme from './theme'; 9 | import { SessionContextProvider } from './Session'; 10 | import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3'; 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | , 27 | document.getElementById('root'), 28 | ); 29 | -------------------------------------------------------------------------------- /client/src/public/checkwaiting/CheckWaiting.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | import { useParams, useHistory } from 'react-router-dom'; 4 | import { CountyLinks } from '../components/CountyLinks'; 5 | import { SearchPlace } from '../components/SearchPlace'; 6 | import { BackToStartLink } from '../components/BackToStartLink'; 7 | 8 | export function CheckWaiting() { 9 | const history = useHistory(); 10 | const { county } = useParams<{ county?: string }>(); 11 | 12 | return ( 13 |
14 | 15 | Aktuálne počty čakajúcich 16 | 17 | 18 | Vyberte kraj, v ktorom sa chcete dať testovať: 19 | 20 | 21 | {county && ( 22 | history.push(`/aktualne-pocty-cakajucich/${county}/${entity.id}`)} 25 | /> 26 | )} 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /api/app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 31 | } 32 | 33 | /** 34 | * Handle an incoming request. 35 | * 36 | * @param Request $request 37 | * @param Closure $next 38 | * @param string|null $guard 39 | * @return mixed 40 | */ 41 | public function handle(Request $request, Closure $next, $guard = null) 42 | { 43 | if ($this->auth->guard($guard)->guest()) { 44 | return response('Unauthorized.', 401); 45 | } 46 | 47 | return $next($request); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/database/migrations/2020_11_06_125023_create_collection_points_users_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('user_id'); 18 | $table->foreign('user_id') 19 | ->references('id') 20 | ->on('users')->onDelete('cascade'); 21 | $table->unsignedBigInteger('collection_point_id'); 22 | $table->foreign('collection_point_id') 23 | ->references('id') 24 | ->on('collection_points')->onDelete('cascade'); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('collection_points_users'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/public/setwaiting/SetWaiting.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | import { useParams, useHistory } from 'react-router-dom'; 4 | import { CountyLinks } from '../components/CountyLinks'; 5 | import { SearchPlace } from '../components/SearchPlace'; 6 | import { BackToStartLink } from '../components/BackToStartLink'; 7 | 8 | export function SetWaiting() { 9 | const history = useHistory(); 10 | const { county } = useParams<{ county?: string }>(); 11 | 12 | return ( 13 |
14 | 15 | Zadať počet čakajúcich 16 | 17 | 18 | Vyberte kraj, v ktorom sa chcete dať testovať: 19 | 20 | 21 | {county && ( 22 | 25 | history.push(`/zadat-pocet-cakajucich/${county}/${entity.id}/register`) 26 | } 27 | /> 28 | )} 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import { cyan, grey } from '@material-ui/core/colors'; 3 | 4 | const primaryColor = cyan['700']; 5 | 6 | const theme = createMuiTheme({ 7 | palette: { 8 | primary: { 9 | main: primaryColor, 10 | light: cyan['50'], 11 | }, 12 | secondary: { 13 | main: primaryColor, 14 | light: cyan['100'], 15 | }, 16 | text: { 17 | primary: grey['700'], 18 | // secondary: grey["50"] 19 | }, 20 | }, 21 | typography: { 22 | h1: { 23 | fontSize: '3rem', 24 | // fontWeight: 400, 25 | color: primaryColor, 26 | }, 27 | subtitle1: { 28 | color: grey['700'], 29 | }, 30 | subtitle2: { 31 | color: grey['700'], 32 | }, 33 | body1: { 34 | color: grey['700'], 35 | }, 36 | h3: { 37 | color: grey['700'], 38 | fontWeight: 800, 39 | }, 40 | h4: { 41 | color: grey['700'], 42 | }, 43 | h5: { 44 | color: grey['700'], 45 | }, 46 | h6: { 47 | color: grey['700'], 48 | }, 49 | caption: { 50 | color: grey['700'], 51 | }, 52 | }, 53 | }); 54 | 55 | export default theme; 56 | -------------------------------------------------------------------------------- /api/artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make( 32 | 'Illuminate\Contracts\Console\Kernel' 33 | ); 34 | 35 | exit($kernel->handle(new ArgvInput, new ConsoleOutput)); 36 | -------------------------------------------------------------------------------- /client/src/hooks/useFetch.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { fetchJson } from '../utils'; 3 | 4 | export function useFetch(url: string, options?: RequestInit, cacheTime?: number) { 5 | const [response, setResponse] = useState(); 6 | const [error, setError] = useState(null); 7 | const [isLoading, setLoading] = useState(true); 8 | const mounted = useRef(true); 9 | 10 | useEffect(() => { 11 | ref.current = { url, options, fetchData }; 12 | }); 13 | 14 | const fetchData = async () => { 15 | try { 16 | setLoading(true); 17 | const res = await fetchJson(ref.current.url, ref.current.options, cacheTime); 18 | if (mounted.current) { 19 | setResponse(res); 20 | } 21 | } catch (error) { 22 | if (mounted.current) { 23 | setError(error); 24 | } 25 | } finally { 26 | setLoading(false); 27 | } 28 | }; 29 | 30 | const ref = useRef({ url, options, fetchData }); 31 | 32 | useEffect(() => { 33 | return () => { 34 | mounted.current = false; 35 | }; 36 | }, []); 37 | 38 | useEffect(() => { 39 | ref.current.fetchData(); 40 | }, [ref, url]); 41 | 42 | return { isLoading, response, error, refresh: fetchData }; 43 | } 44 | -------------------------------------------------------------------------------- /api/app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['auth']->viaRequest('api', function ($request) { 37 | if ($request->input('api_token')) { 38 | return User::where('api_token', $request->input('api_token'))->first(); 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/lumen", 3 | "description": "The Laravel Lumen Framework.", 4 | "keywords": ["framework", "laravel", "lumen"], 5 | "license": "MIT", 6 | "type": "project", 7 | "require": { 8 | "php": "^7.3", 9 | "ext-json": "*", 10 | "ext-openssl": "*", 11 | "ext-simplexml": "*", 12 | "ext-curl": "*", 13 | "google/recaptcha": "^1.2", 14 | "laravel/lumen-framework": "^8.0", 15 | "tymon/jwt-auth": "^1.0" 16 | }, 17 | "require-dev": { 18 | "fzaninotto/faker": "^1.9.1", 19 | "mockery/mockery": "^1.3.1", 20 | "phpunit/phpunit": "^9.4" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "App\\": "app/", 25 | "Database\\Seeders\\": "database/seeders/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "classmap": [ 30 | "tests/" 31 | ] 32 | }, 33 | "config": { 34 | "preferred-install": "dist", 35 | "sort-packages": true, 36 | "optimize-autoloader": true 37 | }, 38 | "minimum-stability": "dev", 39 | "prefer-stable": true, 40 | "scripts": { 41 | "post-root-package-install": [ 42 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /api/database/migrations/2020_10_30_140739_create_entry_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->bigInteger('collection_point_id')->unsigned(); 19 | $table->foreign('collection_point_id')->references('id')->on('collection_points'); 20 | $table->date('day'); 21 | $table->time('arrive'); 22 | $table->integer('length'); 23 | $table->time('departure')->nullable(); 24 | $table->string('admin_note', 250)->nullable(); 25 | $table->boolean('verified')->default(false); 26 | $table->string('token', 40); 27 | $table->index(['collection_point_id', 'day', 'arrive']); 28 | $table->timestamps(); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down() 38 | { 39 | Schema::dropIfExists('entry'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/hooks/useCaptchaToken.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from 'react'; 2 | import { useGoogleReCaptcha } from 'react-google-recaptcha-v3'; 3 | 4 | // https://developers.google.com/recaptcha/docs/v3 5 | // google recaptcha expires in 2 minutes, refresh after 1 minute 6 | const CAPTCHA_REFRESH_INTERVAL = 60000; 7 | 8 | export function useCaptchaToken() { 9 | const googleRecaptchaContextValue = useGoogleReCaptcha(); 10 | const [token, setToken] = useState(''); 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 | 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 | 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 | 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 | 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 |
68 | 69 | 70 | 73 | 74 | {isLoginLoading && } 75 | 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 |
72 |
73 | 74 | 75 | 83 | 84 | 85 |
86 | {(isRegistering || isCaptchaLoading) && } 87 | {registerError && {registerError}} 88 | 97 | 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 | 117 | 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 |
86 |
87 | 88 | 89 | 97 | 98 | 99 | 106 | 107 | 108 |
109 | {(isRegistering || isCaptchaLoading) && } 110 | {registerError && {registerError}} 111 | 120 | 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 | 169 | 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 | 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 | 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 | 141 | {activeStep < steps.length - 1 && ( 142 | 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 | 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 | {/* {'odbernemiesta.sk'}*/} 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 | 137 | {activeStep < steps.length - 1 && ( 138 | 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 | 208 | 216 | 217 |
218 | )} 219 |
220 | {error && {error}} 221 | {(isLoading || isCaptchaTokenLoading) && } 222 | 223 | 224 | 227 | {editingBreak && ( 228 | 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 | 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 | 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 | 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 | --------------------------------------------------------------------------------